| 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 |
* The "Subscription Total" summary on the cart and checkout pages. |
| 14 |
* |
| 15 |
* A subscription cart's Total row states what is charged TODAY, which on a |
| 16 |
* free-trial checkout is $0.00 and on every other checkout is a one-off |
| 17 |
* figure that says nothing about the recurring commitment. This adds a row |
| 18 |
* that states the commitment: the recurring amount, its schedule, and the |
| 19 |
* date of the first renewal charge. |
| 20 |
* |
| 21 |
* **Every figure here must mirror what the engine will actually do**, or the |
| 22 |
* row is a promise the plugin breaks: |
| 23 |
* |
| 24 |
* - The **schedule, trial and cancellation policy come from the FIRST |
| 25 |
* subscription item**, because that is what `Subscriptions::activate_subscription()` |
| 26 |
* snapshots onto the order (one schedule per order — a documented |
| 27 |
* limitation). Reading them from any other item, or blending them, would |
| 28 |
* quote terms the subscription will not be created with. |
| 29 |
* - The **recurring amount sums EVERY subscription item**, because |
| 30 |
* `Subscriptions::create_renewal_order()` re-adds all of them at their |
| 31 |
* current price. Summing only the first item would under-quote a |
| 32 |
* multi-item cart. |
| 33 |
* - Non-subscription items are excluded — they are bought once and never |
| 34 |
* re-added to a renewal order. |
| 35 |
* |
| 36 |
* Classic cart/checkout only. The Blocks Cart/Checkout do not fire these |
| 37 |
* hooks; see `docs/features/woocommerce/subscriptions.md`. |
| 38 |
* |
| 39 |
* @since 2.4.0 |
| 40 |
*/ |
| 41 |
class CartTotals { |
| 42 |
|
| 43 |
/** |
| 44 |
* Store API extension namespace. The blocks script reads the summary |
| 45 |
* from `cart.extensions[ STORE_API_NAMESPACE ]`. |
| 46 |
* |
| 47 |
* @var string |
| 48 |
*/ |
| 49 |
const STORE_API_NAMESPACE = 'better-payment'; |
| 50 |
|
| 51 |
/** |
| 52 |
* Blocks slot-fill script handle. |
| 53 |
* |
| 54 |
* @var string |
| 55 |
*/ |
| 56 |
const BLOCKS_SCRIPT = 'better-payment-wc-subscription-total'; |
| 57 |
|
| 58 |
/** |
| 59 |
* Register the cart/checkout hooks — BOTH page technologies. |
| 60 |
* |
| 61 |
* A shop runs either the classic shortcode cart/checkout or the Cart and |
| 62 |
* Checkout **blocks**, and the two share no rendering path whatsoever. |
| 63 |
* Covering only the classic hooks ships a feature that is invisible on |
| 64 |
* every block-based shop (which is WooCommerce's default for new stores), |
| 65 |
* with nothing in the UI to explain the absence. |
| 66 |
* |
| 67 |
* - **Classic** — `woocommerce_cart_totals_after_order_total` / |
| 68 |
* `woocommerce_review_order_after_order_total`. Both fire inside the |
| 69 |
* totals `<table>` (`cart/cart-totals.php`, `checkout/review-order.php`), |
| 70 |
* so `render()` emits a `<tr>`; anything else breaks the table in |
| 71 |
* every theme. |
| 72 |
* - **Blocks** — the totals are React, so there is no PHP hook to render |
| 73 |
* into. The summary travels as Store API cart data and a slot-fill |
| 74 |
* script paints it. Both halves are required: the script alone has no |
| 75 |
* data, and the data alone has nothing reading it. |
| 76 |
* |
| 77 |
* Both technologies render from the same `summary()`, so a shop cannot |
| 78 |
* see different figures depending on which pages it uses. |
| 79 |
* |
| 80 |
* @return void |
| 81 |
*/ |
| 82 |
public static function register() { |
| 83 |
add_action( 'woocommerce_cart_totals_after_order_total', array( __CLASS__, 'render' ) ); |
| 84 |
add_action( 'woocommerce_review_order_after_order_total', array( __CLASS__, 'render' ) ); |
| 85 |
add_action( 'woocommerce_blocks_loaded', array( __CLASS__, 'register_store_api_data' ) ); |
| 86 |
add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ), 20 ); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Load the stylesheet and the blocks slot-fill script on the |
| 91 |
* cart/checkout pages only — never site-wide. |
| 92 |
* |
| 93 |
* Gated on the page, NOT on the cart holding a subscription. The blocks |
| 94 |
* cart is client-rendered and its contents change without a page load, so |
| 95 |
* a contents-based gate would decide once at page load and then be wrong: |
| 96 |
* remove the last subscription item and the stale script stays, add one |
| 97 |
* back and the script that should paint the row was never loaded. The |
| 98 |
* script self-suppresses when the Store API reports no subscription, so |
| 99 |
* the page check is the only gate that can stay correct. |
| 100 |
* |
| 101 |
* @return void |
| 102 |
*/ |
| 103 |
public static function enqueue_assets() { |
| 104 |
if ( ! function_exists( 'is_cart' ) || ! function_exists( 'is_checkout' ) ) { |
| 105 |
return; |
| 106 |
} |
| 107 |
|
| 108 |
if ( ! is_cart() && ! is_checkout() ) { |
| 109 |
return; |
| 110 |
} |
| 111 |
|
| 112 |
wp_enqueue_style( 'better-payment-wc-subscriptions' ); |
| 113 |
|
| 114 |
// Enqueued unconditionally rather than behind a |
| 115 |
// wp_script_is( 'wc-blocks-checkout', 'registered' ) check: that |
| 116 |
// check depends on WooCommerce having registered its block assets |
| 117 |
// before this hook runs, and if that order ever changes the script is |
| 118 |
// dropped with no error and the row silently vanishes from every |
| 119 |
// block shop. WordPress already does the right thing — an unresolved |
| 120 |
// dependency simply means the script is not printed, which is exactly |
| 121 |
// what a classic-only shop wants (its row is already rendered in PHP). |
| 122 |
wp_register_script( |
| 123 |
self::BLOCKS_SCRIPT, |
| 124 |
BETTER_PAYMENT_ASSETS . '/js/woocommerce-subscription-total.js', |
| 125 |
array( 'wc-blocks-checkout', 'wp-element', 'wp-plugins' ), |
| 126 |
BETTER_PAYMENT_VERSION, |
| 127 |
true |
| 128 |
); |
| 129 |
|
| 130 |
wp_enqueue_script( self::BLOCKS_SCRIPT ); |
| 131 |
} |
| 132 |
|
| 133 |
/** |
| 134 |
* Expose the summary as Store API cart data, so the blocks cart/checkout |
| 135 |
* can render it and — critically — re-render it on every cart change. |
| 136 |
* |
| 137 |
* Registered on `woocommerce_blocks_loaded`; a no-op on shops without the |
| 138 |
* blocks bundle. |
| 139 |
* |
| 140 |
* @return void |
| 141 |
*/ |
| 142 |
public static function register_store_api_data() { |
| 143 |
if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) |
| 144 |
|| ! class_exists( '\Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema' ) ) { |
| 145 |
return; |
| 146 |
} |
| 147 |
|
| 148 |
woocommerce_store_api_register_endpoint_data( |
| 149 |
array( |
| 150 |
'endpoint' => \Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema::IDENTIFIER, |
| 151 |
'namespace' => self::STORE_API_NAMESPACE, |
| 152 |
'data_callback' => array( __CLASS__, 'store_api_payload' ), |
| 153 |
'schema_callback' => array( __CLASS__, 'store_api_schema' ), |
| 154 |
'schema_type' => ARRAY_A, |
| 155 |
) |
| 156 |
); |
| 157 |
} |
| 158 |
|
| 159 |
/** |
| 160 |
* The summary as the blocks client consumes it. |
| 161 |
* |
| 162 |
* Every string arrives **fully formatted and translated**. Currency |
| 163 |
* formatting honours a dozen shop settings (symbol, position, decimals, |
| 164 |
* separators) and the date honours the site's date format and locale — |
| 165 |
* re-implementing either in JS would drift from the classic row on the |
| 166 |
* same shop. The amount is plain text rather than `wc_price()` HTML so |
| 167 |
* the client can render it as a text node instead of injecting markup. |
| 168 |
* |
| 169 |
* @return array |
| 170 |
*/ |
| 171 |
public static function store_api_payload() { |
| 172 |
$summary = self::summary(); |
| 173 |
|
| 174 |
if ( null === $summary ) { |
| 175 |
return array( |
| 176 |
'has_subscription' => false, |
| 177 |
'label' => '', |
| 178 |
'amount' => '', |
| 179 |
'next_billing' => '', |
| 180 |
'cancel_note' => '', |
| 181 |
); |
| 182 |
} |
| 183 |
|
| 184 |
return array( |
| 185 |
'has_subscription' => true, |
| 186 |
'label' => self::label(), |
| 187 |
'amount' => self::plain_price( self::amount_html( $summary ) ), |
| 188 |
'next_billing' => self::next_billing_text( $summary ), |
| 189 |
'cancel_note' => $summary['allow_cancel'] ? self::cancel_note() : '', |
| 190 |
); |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Schema for the Store API payload. |
| 195 |
* |
| 196 |
* @return array |
| 197 |
*/ |
| 198 |
public static function store_api_schema() { |
| 199 |
$string_field = static function ( $description ) { |
| 200 |
return array( |
| 201 |
'description' => $description, |
| 202 |
'type' => 'string', |
| 203 |
'readonly' => true, |
| 204 |
); |
| 205 |
}; |
| 206 |
|
| 207 |
return array( |
| 208 |
'has_subscription' => array( |
| 209 |
'description' => __( 'Whether the cart contains a Better Payment subscription product.', 'better-payment' ), |
| 210 |
'type' => 'boolean', |
| 211 |
'readonly' => true, |
| 212 |
), |
| 213 |
'label' => $string_field( __( 'Row label.', 'better-payment' ) ), |
| 214 |
'amount' => $string_field( __( 'Formatted recurring amount and schedule.', 'better-payment' ) ), |
| 215 |
'next_billing' => $string_field( __( 'Formatted date of the first renewal charge.', 'better-payment' ) ), |
| 216 |
'cancel_note' => $string_field( __( 'Cancellation note, empty when the product does not allow user cancellation.', 'better-payment' ) ), |
| 217 |
); |
| 218 |
} |
| 219 |
|
| 220 |
/** |
| 221 |
* Collect the current cart's subscription items in the shape |
| 222 |
* build_summary() consumes. Returns an empty array when the cart holds |
| 223 |
* no subscription product. |
| 224 |
* |
| 225 |
* @return array[] Ordered as the cart is; see build_summary() for keys. |
| 226 |
*/ |
| 227 |
public static function cart_items() { |
| 228 |
if ( ! function_exists( 'WC' ) || null === WC()->cart ) { |
| 229 |
return array(); |
| 230 |
} |
| 231 |
|
| 232 |
// Totals calculation zeroes a trial-eligible product's price through |
| 233 |
// a scoped filter that is removed the moment calculation ends, so by |
| 234 |
// template time the real price is back. Drop it explicitly anyway: |
| 235 |
// reading a price through that filter would quote the RECURRING |
| 236 |
// amount as $0.00 — the one figure this row exists to state — and |
| 237 |
// the next calculate_totals() re-adds the filter itself, so this |
| 238 |
// cannot leave trial pricing broken. |
| 239 |
Subscriptions::remove_trial_price_filter(); |
| 240 |
|
| 241 |
$items = array(); |
| 242 |
|
| 243 |
foreach ( WC()->cart->get_cart() as $cart_item ) { |
| 244 |
$product = isset( $cart_item['data'] ) ? $cart_item['data'] : null; |
| 245 |
|
| 246 |
if ( ! Subscriptions::product_is_subscription( $product ) ) { |
| 247 |
continue; |
| 248 |
} |
| 249 |
|
| 250 |
$quantity = isset( $cart_item['quantity'] ) ? max( 1, (int) $cart_item['quantity'] ) : 1; |
| 251 |
|
| 252 |
$items[] = array( |
| 253 |
// Respects the shop's cart tax-display setting, so the |
| 254 |
// recurring figure is quoted the same way as the Total |
| 255 |
// directly above it. |
| 256 |
'recurring_total' => (float) wc_get_price_to_display( $product, array( 'qty' => $quantity ) ), |
| 257 |
// What this line costs TODAY — $0.00 when the trial applied. |
| 258 |
// That is the same marker activate_subscription() uses to |
| 259 |
// decide a trial was taken, so both agree by construction. |
| 260 |
'line_total' => isset( $cart_item['line_total'] ) ? (float) $cart_item['line_total'] : 0.0, |
| 261 |
'interval' => max( 1, (int) $product->get_meta( Subscriptions::PRODUCT_INTERVAL_META ) ), |
| 262 |
'period' => Subscriptions::sanitize_period( $product->get_meta( Subscriptions::PRODUCT_PERIOD_META ) ), |
| 263 |
'trial_days' => max( 0, (int) $product->get_meta( Subscriptions::PRODUCT_TRIAL_DAYS_META ) ), |
| 264 |
'allow_cancel' => 'yes' === $product->get_meta( Subscriptions::USER_CANCEL_META ), |
| 265 |
); |
| 266 |
} |
| 267 |
|
| 268 |
return $items; |
| 269 |
} |
| 270 |
|
| 271 |
/** |
| 272 |
* Pure: reduce subscription cart items to the summary this row renders. |
| 273 |
* |
| 274 |
* @param mixed $items Items from cart_items() — a list of arrays, each |
| 275 |
* with `recurring_total`, `line_total`, `interval`, |
| 276 |
* `period`, `trial_days` and `allow_cancel`. Anything |
| 277 |
* else yields null rather than a garbage quote. |
| 278 |
* @param int $now Base timestamp for the next-payment date (0 = now). |
| 279 |
* @return array|null Null when there is nothing to summarise, else |
| 280 |
* `total`, `interval`, `period`, `trial_days`, |
| 281 |
* `trial_applied`, `next_payment`, `allow_cancel`. |
| 282 |
*/ |
| 283 |
public static function build_summary( $items, $now = 0 ) { |
| 284 |
if ( ! is_array( $items ) || empty( $items ) ) { |
| 285 |
return null; |
| 286 |
} |
| 287 |
|
| 288 |
$items = array_values( $items ); |
| 289 |
$first = $items[0]; |
| 290 |
|
| 291 |
$interval = isset( $first['interval'] ) ? max( 1, (int) $first['interval'] ) : 1; |
| 292 |
$period = Subscriptions::sanitize_period( isset( $first['period'] ) ? $first['period'] : 'month' ); |
| 293 |
$trial_days = isset( $first['trial_days'] ) ? max( 0, (int) $first['trial_days'] ) : 0; |
| 294 |
|
| 295 |
// The trial was taken iff the customer is actually checking out at $0 |
| 296 |
// for this line — exactly the test activate_subscription() applies to |
| 297 |
// the placed order, so the quoted first-charge date matches the one |
| 298 |
// the subscription is created with. |
| 299 |
$trial_applied = $trial_days > 0 && abs( isset( $first['line_total'] ) ? (float) $first['line_total'] : 0.0 ) < 0.01; |
| 300 |
|
| 301 |
$total = 0.0; |
| 302 |
foreach ( $items as $item ) { |
| 303 |
$total += isset( $item['recurring_total'] ) ? (float) $item['recurring_total'] : 0.0; |
| 304 |
} |
| 305 |
|
| 306 |
if ( $total <= 0 ) { |
| 307 |
// Nothing recurring to quote (a free subscription product). A |
| 308 |
// "$0.00 / month" row tells the customer nothing. |
| 309 |
return null; |
| 310 |
} |
| 311 |
|
| 312 |
return array( |
| 313 |
'total' => $total, |
| 314 |
'interval' => $interval, |
| 315 |
'period' => $period, |
| 316 |
'trial_days' => $trial_days, |
| 317 |
'trial_applied' => $trial_applied, |
| 318 |
'next_payment' => $trial_applied |
| 319 |
? Subscriptions::trial_end_timestamp( $trial_days, $now ) |
| 320 |
: Subscriptions::next_payment_timestamp( $interval, $period, $now ), |
| 321 |
// The cancellation promise follows the SAME item the policy is |
| 322 |
// snapshotted from at activation, so the row never offers a |
| 323 |
// cancel button the customer will not get. |
| 324 |
'allow_cancel' => ! empty( $first['allow_cancel'] ), |
| 325 |
); |
| 326 |
} |
| 327 |
|
| 328 |
/** |
| 329 |
* Summary for the current cart, or null when there is nothing to show. |
| 330 |
* |
| 331 |
* @return array|null |
| 332 |
*/ |
| 333 |
public static function summary() { |
| 334 |
return self::build_summary( self::cart_items() ); |
| 335 |
} |
| 336 |
|
| 337 |
/** |
| 338 |
* The row label. |
| 339 |
* |
| 340 |
* @return string |
| 341 |
*/ |
| 342 |
public static function label() { |
| 343 |
return __( 'Subscription Total', 'better-payment' ); |
| 344 |
} |
| 345 |
|
| 346 |
/** |
| 347 |
* The conditional cancellation note. |
| 348 |
* |
| 349 |
* @return string |
| 350 |
*/ |
| 351 |
public static function cancel_note() { |
| 352 |
return __( 'Cancel anytime from your account.', 'better-payment' ); |
| 353 |
} |
| 354 |
|
| 355 |
/** |
| 356 |
* Recurring amount + schedule, as `wc_price()` HTML. |
| 357 |
* |
| 358 |
* Formatted with describe_schedule(), the same helper behind the |
| 359 |
* product-page price suffix, so the customer reads identical wording in |
| 360 |
* both places. |
| 361 |
* |
| 362 |
* @param array $summary From build_summary(). |
| 363 |
* @return string |
| 364 |
*/ |
| 365 |
public static function amount_html( $summary ) { |
| 366 |
return sprintf( |
| 367 |
/* translators: 1: recurring price, 2: billing schedule (e.g. "month" or "every 3 months") */ |
| 368 |
__( '%1$s / %2$s', 'better-payment' ), |
| 369 |
wc_price( $summary['total'] ), |
| 370 |
Subscriptions::describe_schedule( $summary['interval'], $summary['period'] ) |
| 371 |
); |
| 372 |
} |
| 373 |
|
| 374 |
/** |
| 375 |
* The next-billing line. |
| 376 |
* |
| 377 |
* @param array $summary From build_summary(). |
| 378 |
* @return string |
| 379 |
*/ |
| 380 |
public static function next_billing_text( $summary ) { |
| 381 |
return sprintf( |
| 382 |
/* translators: %s: date of the first renewal charge */ |
| 383 |
__( 'Next billing on: %s', 'better-payment' ), |
| 384 |
date_i18n( get_option( 'date_format' ), $summary['next_payment'] ) |
| 385 |
); |
| 386 |
} |
| 387 |
|
| 388 |
/** |
| 389 |
* Pure: `wc_price()` HTML reduced to plain text ("$220.00 / month"). |
| 390 |
* |
| 391 |
* The currency symbol arrives as an HTML entity for most currencies |
| 392 |
* (`$`, `€`), so stripping tags alone would render the entity |
| 393 |
* literally in a React text node. |
| 394 |
* |
| 395 |
* @param string $price_html Formatted price HTML. |
| 396 |
* @return string |
| 397 |
*/ |
| 398 |
public static function plain_price( $price_html ) { |
| 399 |
return trim( html_entity_decode( wp_strip_all_tags( (string) $price_html ), ENT_QUOTES, 'UTF-8' ) ); |
| 400 |
} |
| 401 |
|
| 402 |
/** |
| 403 |
* Render the classic "Subscription Total" row. |
| 404 |
* |
| 405 |
* @return void |
| 406 |
*/ |
| 407 |
public static function render() { |
| 408 |
$summary = self::summary(); |
| 409 |
|
| 410 |
if ( null === $summary ) { |
| 411 |
return; |
| 412 |
} |
| 413 |
|
| 414 |
$label = self::label(); |
| 415 |
$amount = self::amount_html( $summary ); |
| 416 |
$next_billing = self::next_billing_text( $summary ); |
| 417 |
|
| 418 |
?> |
| 419 |
<tr class="bp-subscription-total"> |
| 420 |
<th><?php echo esc_html( $label ); ?></th> |
| 421 |
<td data-title="<?php echo esc_attr( $label ); ?>"> |
| 422 |
<span class="bp-subscription-total__amount"><?php echo wp_kses_post( $amount ); ?></span> |
| 423 |
<span class="bp-subscription-total__meta"><?php echo esc_html( $next_billing ); ?></span> |
| 424 |
<?php if ( $summary['allow_cancel'] ) : ?> |
| 425 |
<span class="bp-subscription-total__meta bp-subscription-total__cancel"> |
| 426 |
<?php echo esc_html( self::cancel_note() ); ?> |
| 427 |
</span> |
| 428 |
<?php endif; ?> |
| 429 |
</td> |
| 430 |
</tr> |
| 431 |
<?php |
| 432 |
} |
| 433 |
} |
| 434 |
|