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 / CartTotals.php

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

434 lines 17.4 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 /**
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 * (`&#036;`, `&euro;`), 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