| 1 |
<?php |
| 2 |
|
| 3 |
namespace Better_Payment\Lite\WooCommerce; |
| 4 |
|
| 5 |
/** |
| 6 |
* Exit if accessed directly |
| 7 |
*/ |
| 8 |
if ( ! defined( 'ABSPATH' ) ) { |
| 9 |
exit; |
| 10 |
} |
| 11 |
|
| 12 |
/** |
| 13 |
* Customer lifecycle emails for Better Payment subscriptions — |
| 14 |
* "subscription cancelled" and "subscription completed". |
| 15 |
* |
| 16 |
* Built from the standard WooCommerce email pattern (WC_Email subclass + |
| 17 |
* delayed send + reactivation guard), mapped onto Better Payment's own |
| 18 |
* statuses: |
| 19 |
* |
| 20 |
* cancelled — the ONLY status with a reactivation route back to active |
| 21 |
* (Subscriptions::reactivate() accepts cancelled/past_due), so |
| 22 |
* its email is sent after a grace delay. Two guards keep a |
| 23 |
* reactivated subscription from being told it is cancelled: |
| 24 |
* subscription_reactivated unschedules the pending event, and |
| 25 |
* the send callback re-checks the status is STILL 'cancelled' |
| 26 |
* at send time — the re-check is the authoritative guard, the |
| 27 |
* unschedule just avoids a pointless cron wake-up. |
| 28 |
* completed — the subscription's schedule ended (the "expired" case in |
| 29 |
* other subscription systems). No core flow completes a subscription |
| 30 |
* any more (the renewal-cap feature was removed); the email |
| 31 |
* remains for integrations that fire the action. Terminal with |
| 32 |
* NO reactivation route, so the email sends immediately; a |
| 33 |
* grace delay would protect nothing. |
| 34 |
* |
| 35 |
* The emails themselves are ordinary WC_Email registrations |
| 36 |
* (Emails\SubscriptionCancelledEmail / Emails\SubscriptionCompletedEmail), |
| 37 |
* so the shop owner enables/disables them and edits subject, heading and |
| 38 |
* additional content from WooCommerce → Settings → Emails — no Better |
| 39 |
* Payment settings are added. Both are enabled by default, like |
| 40 |
* WooCommerce's own customer emails. |
| 41 |
* |
| 42 |
* This registrar is deliberately WC-free (no WC_Email reference at parse |
| 43 |
* time) so the scheduling logic runs in the WooCommerce-less test |
| 44 |
* environment; only the email classes themselves extend WC_Email, and they |
| 45 |
* are instantiated solely inside the `woocommerce_email_classes` filter, |
| 46 |
* which only WooCommerce fires. |
| 47 |
* |
| 48 |
* Only loaded when WooCommerce is active (see Loader::register()). |
| 49 |
* |
| 50 |
* @since 2.4.0 |
| 51 |
*/ |
| 52 |
class Emails { |
| 53 |
|
| 54 |
/** |
| 55 |
* WC_Email ids (WooCommerce → Settings → Emails rows). Template file |
| 56 |
* names derive from these — see template_html()/template_plain(). |
| 57 |
*/ |
| 58 |
const CANCELLED_EMAIL_ID = 'bp_subscription_cancelled'; |
| 59 |
const COMPLETED_EMAIL_ID = 'bp_subscription_completed'; |
| 60 |
|
| 61 |
/** |
| 62 |
* Single-event cron hook that sends the delayed cancelled email. |
| 63 |
* Args: array( int $order_id ) — the id must stay a plain int, because |
| 64 |
* unscheduling matches args by exact serialization. |
| 65 |
*/ |
| 66 |
const CANCELLED_SEND_HOOK = 'better_payment_woocommerce_send_cancelled_email'; |
| 67 |
|
| 68 |
/** |
| 69 |
* Notification hooks the WC_Email classes bind their trigger() to. |
| 70 |
* Fired through notify() after WC()->mailer() has instantiated the |
| 71 |
* email classes. |
| 72 |
*/ |
| 73 |
const CANCELLED_NOTIFICATION = 'better_payment/woocommerce/subscription_cancelled_email_notification'; |
| 74 |
const COMPLETED_NOTIFICATION = 'better_payment/woocommerce/subscription_completed_email_notification'; |
| 75 |
|
| 76 |
/** |
| 77 |
* Wire the feature. Called from Loader::register(), i.e. only when |
| 78 |
* WooCommerce is active. |
| 79 |
* |
| 80 |
* @return void |
| 81 |
*/ |
| 82 |
public static function register() { |
| 83 |
add_filter( 'woocommerce_email_classes', array( __CLASS__, 'register_email_classes' ) ); |
| 84 |
|
| 85 |
// Cancelled: schedule after a grace delay; reactivation unschedules. |
| 86 |
add_action( 'better_payment/woocommerce/subscription_cancelled', array( __CLASS__, 'schedule_cancelled_email' ) ); |
| 87 |
add_action( 'better_payment/woocommerce/subscription_reactivated', array( __CLASS__, 'unschedule_cancelled_email' ) ); |
| 88 |
add_action( self::CANCELLED_SEND_HOOK, array( __CLASS__, 'send_cancelled_email' ) ); |
| 89 |
|
| 90 |
// Completed: terminal, no reactivation route — send immediately. |
| 91 |
add_action( 'better_payment/woocommerce/subscription_completed', array( __CLASS__, 'send_completed_email' ) ); |
| 92 |
|
| 93 |
register_deactivation_hook( BETTER_PAYMENT_FILE, array( __CLASS__, 'unschedule_all' ) ); |
| 94 |
} |
| 95 |
|
| 96 |
/** |
| 97 |
* Clear every pending delayed-email event on plugin deactivation. |
| 98 |
* wp_unschedule_hook() drops the hook's events regardless of args, so |
| 99 |
* per-order events don't linger pointing at an unloaded listener. |
| 100 |
* |
| 101 |
* @return void |
| 102 |
*/ |
| 103 |
public static function unschedule_all() { |
| 104 |
wp_unschedule_hook( self::CANCELLED_SEND_HOOK ); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Filter callback for `woocommerce_email_classes`: add the two |
| 109 |
* subscription lifecycle emails. The subclasses extend WC_Email, so |
| 110 |
* they are only instantiated here — inside WooCommerce's own mailer |
| 111 |
* bootstrap — never at module load. |
| 112 |
* |
| 113 |
* @param mixed $emails Registered email instances (id => WC_Email). |
| 114 |
* @return mixed |
| 115 |
*/ |
| 116 |
public static function register_email_classes( $emails ) { |
| 117 |
if ( ! is_array( $emails ) || ! class_exists( 'WC_Email' ) ) { |
| 118 |
return $emails; |
| 119 |
} |
| 120 |
|
| 121 |
$emails[ self::CANCELLED_EMAIL_ID ] = new Emails\SubscriptionCancelledEmail(); |
| 122 |
$emails[ self::COMPLETED_EMAIL_ID ] = new Emails\SubscriptionCompletedEmail(); |
| 123 |
|
| 124 |
return $emails; |
| 125 |
} |
| 126 |
|
| 127 |
/* --------------------------------------------------------------------- |
| 128 |
* Cancelled email — delayed send + reactivation guard |
| 129 |
* ------------------------------------------------------------------- */ |
| 130 |
|
| 131 |
/** |
| 132 |
* The grace delay (seconds) between a subscription being cancelled and |
| 133 |
* the customer email going out. During the window a reactivation |
| 134 |
* silently discards the email. 0 (or negative) sends immediately. |
| 135 |
* |
| 136 |
* @return int |
| 137 |
*/ |
| 138 |
public static function cancelled_email_delay() { |
| 139 |
/** |
| 140 |
* Filters the delay before the "subscription cancelled" customer |
| 141 |
* email is sent. Return 0 to send immediately. |
| 142 |
* |
| 143 |
* @since 2.4.0 |
| 144 |
* |
| 145 |
* @param int $delay Delay in seconds. Default HOUR_IN_SECONDS. |
| 146 |
*/ |
| 147 |
return (int) apply_filters( 'better_payment/woocommerce/cancelled_email_delay', HOUR_IN_SECONDS ); |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Consume `better_payment/woocommerce/subscription_cancelled`: queue the |
| 152 |
* customer email after the grace delay. Re-cancelling (cancel → |
| 153 |
* reactivate → cancel) replaces any pending event rather than stacking |
| 154 |
* a second one. |
| 155 |
* |
| 156 |
* @param mixed $order Parent (subscription) order. |
| 157 |
* @return void |
| 158 |
*/ |
| 159 |
public static function schedule_cancelled_email( $order ) { |
| 160 |
$order_id = self::order_id( $order ); |
| 161 |
|
| 162 |
if ( $order_id < 1 ) { |
| 163 |
return; |
| 164 |
} |
| 165 |
|
| 166 |
$delay = self::cancelled_email_delay(); |
| 167 |
|
| 168 |
if ( $delay <= 0 ) { |
| 169 |
self::send_cancelled_email( $order_id ); |
| 170 |
return; |
| 171 |
} |
| 172 |
|
| 173 |
// Dedupe: exactly one pending event per subscription. |
| 174 |
wp_clear_scheduled_hook( self::CANCELLED_SEND_HOOK, array( $order_id ) ); |
| 175 |
wp_schedule_single_event( time() + $delay, self::CANCELLED_SEND_HOOK, array( $order_id ) ); |
| 176 |
} |
| 177 |
|
| 178 |
/** |
| 179 |
* Consume `better_payment/woocommerce/subscription_reactivated`: the |
| 180 |
* subscription is live again — discard any pending cancelled email. |
| 181 |
* |
| 182 |
* @param mixed $order Parent (subscription) order. |
| 183 |
* @return void |
| 184 |
*/ |
| 185 |
public static function unschedule_cancelled_email( $order ) { |
| 186 |
$order_id = self::order_id( $order ); |
| 187 |
|
| 188 |
if ( $order_id < 1 ) { |
| 189 |
return; |
| 190 |
} |
| 191 |
|
| 192 |
wp_clear_scheduled_hook( self::CANCELLED_SEND_HOOK, array( $order_id ) ); |
| 193 |
} |
| 194 |
|
| 195 |
/** |
| 196 |
* Send the "subscription cancelled" email — the delayed cron callback |
| 197 |
* (and the immediate path when the delay is 0). |
| 198 |
* |
| 199 |
* The status re-check is the authoritative reactivation guard: even if |
| 200 |
* an unschedule was missed, a subscription that is no longer |
| 201 |
* 'cancelled' is never emailed about a cancellation. |
| 202 |
* |
| 203 |
* @param mixed $order_id Parent (subscription) order id. |
| 204 |
* @return void |
| 205 |
*/ |
| 206 |
public static function send_cancelled_email( $order_id ) { |
| 207 |
$order_id = absint( $order_id ); |
| 208 |
|
| 209 |
if ( $order_id < 1 || ! function_exists( 'wc_get_order' ) ) { |
| 210 |
return; |
| 211 |
} |
| 212 |
|
| 213 |
$order = wc_get_order( $order_id ); |
| 214 |
|
| 215 |
if ( ! $order instanceof \WC_Order ) { |
| 216 |
return; |
| 217 |
} |
| 218 |
|
| 219 |
if ( ! self::should_send( (string) $order->get_meta( Subscriptions::STATUS_META ), 'cancelled' ) ) { |
| 220 |
return; |
| 221 |
} |
| 222 |
|
| 223 |
self::notify( self::CANCELLED_NOTIFICATION, $order_id ); |
| 224 |
} |
| 225 |
|
| 226 |
/* --------------------------------------------------------------------- |
| 227 |
* Completed email — immediate |
| 228 |
* ------------------------------------------------------------------- */ |
| 229 |
|
| 230 |
/** |
| 231 |
* Consume `better_payment/woocommerce/subscription_completed`: the |
| 232 |
* subscription ended (fired by integrations only — no core flow |
| 233 |
* completes a subscription). Terminal with no reactivation route, so |
| 234 |
* the email goes out immediately — a delay would guard nothing. |
| 235 |
* |
| 236 |
* @param mixed $order Parent (subscription) order. |
| 237 |
* @return void |
| 238 |
*/ |
| 239 |
public static function send_completed_email( $order ) { |
| 240 |
$order_id = self::order_id( $order ); |
| 241 |
|
| 242 |
if ( $order_id < 1 || ! function_exists( 'wc_get_order' ) ) { |
| 243 |
return; |
| 244 |
} |
| 245 |
|
| 246 |
self::notify( self::COMPLETED_NOTIFICATION, $order_id ); |
| 247 |
} |
| 248 |
|
| 249 |
/** |
| 250 |
* Fire a notification hook with the mailer loaded. WC()->mailer() |
| 251 |
* instantiates every registered email class (running our |
| 252 |
* `woocommerce_email_classes` filter), whose constructors bind |
| 253 |
* trigger() to these hooks — without it the do_action falls on nothing. |
| 254 |
* |
| 255 |
* @param string $hook Notification hook name. |
| 256 |
* @param int $order_id Parent (subscription) order id. |
| 257 |
* @return void |
| 258 |
*/ |
| 259 |
protected static function notify( $hook, $order_id ) { |
| 260 |
if ( ! function_exists( 'WC' ) ) { |
| 261 |
return; |
| 262 |
} |
| 263 |
|
| 264 |
WC()->mailer(); |
| 265 |
|
| 266 |
do_action( $hook, $order_id ); |
| 267 |
} |
| 268 |
|
| 269 |
/* --------------------------------------------------------------------- |
| 270 |
* Pure helpers (unit-tested without WooCommerce) |
| 271 |
* ------------------------------------------------------------------- */ |
| 272 |
|
| 273 |
/** |
| 274 |
* Pure: the order id from a WC_Order-ish object or a numeric id. |
| 275 |
* Tolerant on purpose — the lifecycle hooks pass WC_Order, tests and |
| 276 |
* the cron path pass plain ints. |
| 277 |
* |
| 278 |
* @param mixed $order Order object or id. |
| 279 |
* @return int 0 when no id can be resolved. |
| 280 |
*/ |
| 281 |
public static function order_id( $order ) { |
| 282 |
if ( is_object( $order ) && method_exists( $order, 'get_id' ) ) { |
| 283 |
return (int) $order->get_id(); |
| 284 |
} |
| 285 |
|
| 286 |
if ( is_numeric( $order ) ) { |
| 287 |
return (int) $order; |
| 288 |
} |
| 289 |
|
| 290 |
return 0; |
| 291 |
} |
| 292 |
|
| 293 |
/** |
| 294 |
* Pure: whether a lifecycle email may still be sent — the subscription's |
| 295 |
* current status must be exactly the status the email announces. This is |
| 296 |
* what keeps a reactivated subscription from receiving a stale |
| 297 |
* "cancelled" email. |
| 298 |
* |
| 299 |
* @param string $current Current `_bp_subscription_status` meta value. |
| 300 |
* @param string $required Status the email announces. |
| 301 |
* @return bool |
| 302 |
*/ |
| 303 |
public static function should_send( $current, $required ) { |
| 304 |
return '' !== (string) $required && (string) $current === (string) $required; |
| 305 |
} |
| 306 |
|
| 307 |
/** |
| 308 |
* Pure: the plugin template root the email templates resolve against. |
| 309 |
* |
| 310 |
* @return string |
| 311 |
*/ |
| 312 |
public static function template_base() { |
| 313 |
return ( defined( 'BETTER_PAYMENT_PATH' ) ? BETTER_PAYMENT_PATH : '' ) . 'templates/'; |
| 314 |
} |
| 315 |
|
| 316 |
/** |
| 317 |
* Pure: HTML template path (relative to template_base()) for an email id. |
| 318 |
* Ids are underscored (`bp_subscription_cancelled`), files hyphenated |
| 319 |
* (`emails/bp-subscription-cancelled.php`) per WooCommerce convention. |
| 320 |
* |
| 321 |
* @param string $email_id WC_Email id. |
| 322 |
* @return string |
| 323 |
*/ |
| 324 |
public static function template_html( $email_id ) { |
| 325 |
return 'emails/' . str_replace( '_', '-', (string) $email_id ) . '.php'; |
| 326 |
} |
| 327 |
|
| 328 |
/** |
| 329 |
* Pure: plain-text template path for an email id. |
| 330 |
* |
| 331 |
* @param string $email_id WC_Email id. |
| 332 |
* @return string |
| 333 |
*/ |
| 334 |
public static function template_plain( $email_id ) { |
| 335 |
return 'emails/plain/' . str_replace( '_', '-', (string) $email_id ) . '.php'; |
| 336 |
} |
| 337 |
|
| 338 |
/** |
| 339 |
* The customer-facing URL for a subscription: the My Account → |
| 340 |
* Subscriptions single view when the tab is enabled, else WooCommerce's |
| 341 |
* own view-order page (which always exists for a logged-in customer). |
| 342 |
* |
| 343 |
* @param mixed $order Parent (subscription) order. |
| 344 |
* @return string '' when no URL can be built. |
| 345 |
*/ |
| 346 |
public static function subscription_view_url( $order ) { |
| 347 |
if ( ! $order instanceof \WC_Order ) { |
| 348 |
return ''; |
| 349 |
} |
| 350 |
|
| 351 |
if ( 'yes' === Subscriptions::setting( 'myaccount_tab' ) ) { |
| 352 |
$url = (string) MyAccount::view_url( $order->get_id() ); |
| 353 |
|
| 354 |
if ( '' !== $url ) { |
| 355 |
return $url; |
| 356 |
} |
| 357 |
} |
| 358 |
|
| 359 |
return (string) $order->get_view_order_url(); |
| 360 |
} |
| 361 |
} |
| 362 |
|