PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.4.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.4.0
2.4.0 2.3.0 2.2.0 2.1.0 2.0.0 1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 All 65 releases
subscription / includes / Admin / PlanPresenter.php

PlanPresenter.php in Subscriptions for WooCommerce with Stripe Recurring Payments 2.4.0, at includes/Admin/PlanPresenter.php

490 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plan presenter - maps PlanRepository rows to the admin template shape.
4 *
5 * Builds the array contract the Plans admin templates render (name / type /
6 * terms / products / rows …) from the real DB rows, so the templates stay
7 * dumb view files.
8 *
9 * @package SpringDevs\Subscription\Admin
10 */
11
12 namespace SpringDevs\Subscription\Admin;
13
14 use SpringDevs\Subscription\Illuminate\Helper;
15 use SpringDevs\Subscription\Illuminate\Plans\PlanRepository;
16
17 /**
18 * Plan presenter.
19 */
20 class PlanPresenter {
21
22 /**
23 * Build every plan group in the template contract, keyed by group id.
24 *
25 * @return array
26 */
27 public static function all() {
28 $plans = array();
29
30 foreach ( PlanRepository::get_groups() as $group ) {
31 $plans[ $group['id'] ] = self::group( $group['id'] );
32 }
33
34 return array_filter( $plans );
35 }
36
37 /**
38 * Build a single plan group tree in the template contract.
39 *
40 * @param int $group_id Group id.
41 *
42 * @return array|null
43 */
44 public static function group( $group_id ) {
45 $tree = PlanRepository::get_group_tree( $group_id );
46
47 if ( ! $tree ) {
48 return null;
49 }
50
51 $order = is_array( $tree['data'] ) ? $tree['data'] : array();
52
53 // Saved drag-and-drop order. Sorting the tree's plans here also orders
54 // the duration rows inside every product's price table.
55 $tree['plans'] = self::apply_order( $tree['plans'], $order['term_order'] ?? array(), false );
56
57 $type_key = $tree['type_key'];
58 $terms = array();
59
60 foreach ( $tree['plans'] as $plan ) {
61 $terms[] = array(
62 'id' => $plan['id'],
63 'name' => $plan['title'],
64 'breakdown' => self::breakdown( $plan ),
65 'status' => $plan['status'],
66 'chips' => self::term_chips( $plan ),
67 );
68 }
69
70 return array(
71 'id' => $tree['id'],
72 'name' => $tree['title'],
73 'type' => $type_key,
74 'status' => $tree['status'],
75 'created' => self::ago( $tree['created_at'] ),
76 'edited' => self::ago( $tree['updated_at'] ),
77 'terms' => $terms,
78 'products' => self::apply_order( self::products( $tree, $type_key ), $order['product_order'] ?? array(), true ),
79 );
80 }
81
82 /**
83 * Reorder items by a saved list of ids (the detail page's drag-and-drop).
84 *
85 * Items missing from the list keep their default relative order and go
86 * after the listed ones — or before them when $unlisted_first, so a newly
87 * attached product still shows at the top as it did before any reorder.
88 *
89 * @param array $items Items, each with an `id`.
90 * @param array $ids Saved order (ids).
91 * @param bool $unlisted_first Put unlisted items first.
92 *
93 * @return array
94 */
95 protected static function apply_order( $items, $ids, $unlisted_first ) {
96 if ( empty( $ids ) || ! is_array( $ids ) ) {
97 return $items;
98 }
99
100 $position = array_flip( array_map( 'intval', array_values( $ids ) ) );
101 $listed = array();
102 $unlisted = array();
103
104 foreach ( $items as $item ) {
105 $id = (int) $item['id'];
106 if ( isset( $position[ $id ] ) ) {
107 $listed[ $position[ $id ] ] = $item;
108 } else {
109 $unlisted[] = $item;
110 }
111 }
112
113 ksort( $listed );
114 $listed = array_values( $listed );
115
116 return $unlisted_first ? array_merge( $unlisted, $listed ) : array_merge( $listed, $unlisted );
117 }
118
119 /**
120 * Group relations by product into the products[] contract (read-only).
121 *
122 * @param array $tree Group tree (plans → relations).
123 * @param string $type_key Plan type key.
124 *
125 * @return array
126 */
127 protected static function products( $tree, $type_key ) {
128 $by_product = array();
129
130 // First pass: register each connected product + index its relations by
131 // [vid][plan_id] (vid 0 = the product-level / seed connection).
132 foreach ( $tree['plans'] as $plan ) {
133 foreach ( $plan['relations'] as $relation ) {
134 if ( PlanRepository::REL_PRODUCT !== (int) $relation['type'] ) {
135 continue;
136 }
137
138 $oid = (int) $relation['oid'];
139 $vid = (int) $relation['vid'];
140 $plan_id = (int) $plan['id'];
141
142 if ( ! isset( $by_product[ $oid ] ) ) {
143 $product = function_exists( 'wc_get_product' ) ? wc_get_product( $oid ) : null;
144 $is_variable = $product ? $product->is_type( 'variable' ) : false;
145 $image_id = $product ? $product->get_image_id() : 0;
146 $by_product[ $oid ] = array(
147 'id' => $oid,
148 'name' => $product ? $product->get_name() : sprintf( '#%d', $oid ),
149 'image' => $image_id ? wp_get_attachment_image_url( $image_id, array( 88, 88 ) ) : '',
150 'is_variable' => $is_variable,
151 'base_price' => ( $product && ! $is_variable ) ? self::money( (float) $product->get_price() ) : '',
152 'one_time_on' => $product ? ( 'yes' === $product->get_meta( '_subscrpt_one_time_enabled' ) ) : false,
153 'ot_regular' => ( $product && ! $is_variable ) ? self::one_time_price( $product ) : '',
154 'ot_offer' => ( $product && ! $is_variable ) ? (string) $product->get_sale_price() : '',
155 'user_cancel' => $product ? Helper::can_user_cancel( $product->get_meta( '_subscrpt_user_cancel' ) ) : true,
156 'edit_url' => get_edit_post_link( $oid, 'raw' ),
157 'view_url' => get_permalink( $oid ),
158 'rows' => array(),
159 'variations' => array(),
160 '_rel' => array(),
161 );
162 }
163
164 $by_product[ $oid ]['_rel'][ $vid ][ $plan_id ] = $relation;
165 }
166 }
167
168 // Second pass: build the price cards.
169 foreach ( $by_product as $oid => &$entry ) {
170 $rel = $entry['_rel'];
171
172 if ( $entry['is_variable'] ) {
173 // One card per real variation. Each (variation × plan) price uses
174 // the variation's own relation, falling back to the product-level
175 // (vid 0) seed; editing seeds a per-variation relation.
176 $product = function_exists( 'wc_get_product' ) ? wc_get_product( $oid ) : null;
177 $children = $product ? $product->get_children() : array();
178
179 foreach ( $children as $cvid ) {
180 $cvid = (int) $cvid;
181 $rows = array();
182 foreach ( $tree['plans'] as $plan ) {
183 $plan_id = (int) $plan['id'];
184 $vid_rel = isset( $rel[ $cvid ][ $plan_id ] ) ? $rel[ $cvid ][ $plan_id ] : null;
185 $seed_rel = isset( $rel[0][ $plan_id ] ) ? $rel[0][ $plan_id ] : null;
186 $use = $vid_rel ? $vid_rel : $seed_rel;
187 if ( ! $use ) {
188 continue;
189 }
190 // relation_id 0 → not saved for this variation yet (seeded).
191 $rows[] = self::row( $plan, $use, $type_key, $vid_rel ? (int) $vid_rel['id'] : 0, $cvid );
192 }
193 if ( empty( $rows ) ) {
194 continue;
195 }
196 $variation = function_exists( 'wc_get_product' ) ? wc_get_product( $cvid ) : null;
197 $entry['variations'][] = array(
198 'vid' => $cvid,
199 'name' => self::variation_name( $variation, $entry['name'] ),
200 'base_price' => $variation ? self::money( (float) $variation->get_price() ) : '-',
201 'one_time_on' => $variation ? ( 'yes' === $variation->get_meta( '_subscrpt_one_time_enabled' ) ) : false,
202 'ot_regular' => self::one_time_price( $variation ),
203 'ot_offer' => $variation ? (string) $variation->get_sale_price() : '',
204 'rows' => $rows,
205 );
206 }
207 } else {
208 // Simple product: one card from the vid 0 relations.
209 foreach ( $tree['plans'] as $plan ) {
210 $plan_id = (int) $plan['id'];
211 if ( ! isset( $rel[0][ $plan_id ] ) ) {
212 continue;
213 }
214 $entry['rows'][] = self::row( $plan, $rel[0][ $plan_id ], $type_key, (int) $rel[0][ $plan_id ]['id'], 0 );
215 }
216 }
217
218 unset( $entry['_rel'] );
219 }
220 unset( $entry );
221
222 $products = array_values( $by_product );
223
224 // Sort by product ID (newest first).
225 usort(
226 $products,
227 function ( $a, $b ) {
228 return (int) $b['id'] - (int) $a['id'];
229 }
230 );
231
232 return $products;
233 }
234
235 /**
236 * Display name for a variation: its attribute values (e.g. "Large, Red"),
237 * falling back to the WC formatted name or the parent name.
238 *
239 * @param \WC_Product|null $variation Variation product.
240 * @param string $parent_name Parent product name.
241 *
242 * @return string
243 */
244 protected static function variation_name( $variation, $parent_name ) {
245 if ( ! $variation ) {
246 return $parent_name;
247 }
248
249 $attributes = array_filter( array_values( $variation->get_variation_attributes() ) );
250
251 return $attributes ? implode( ', ', $attributes ) : $variation->get_name();
252 }
253
254 /**
255 * Build one read-only price row for a (plan term × product) relation.
256 *
257 * @param array $plan Plan term row.
258 * @param array $relation Relation row (or the seed relation for a
259 * not-yet-saved variation price).
260 * @param string $type_key Plan type key.
261 * @param int|null $relation_id Relation id to save against; 0 = new (seeded),
262 * null = use the relation's own id.
263 * @param int $vid Variation id this row targets (0 = product).
264 *
265 * @return array
266 */
267 protected static function row( $plan, $relation, $type_key, $relation_id = null, $vid = 0 ) {
268 $data = $relation['data'];
269
270 $regular = isset( $data['regular_price'] ) ? (string) $data['regular_price'] : '';
271 $selling = isset( $data['sale_price'] ) ? (string) $data['sale_price'] : '';
272 $discount_type = $data['discount_type'] ?? 'percentage';
273 $discount_value = isset( $data['discount_value'] ) ? (string) $data['discount_value'] : '0';
274
275 // A real offer exists only when the effective price is below the regular
276 // (an explicit sale price or a discount). Without one, offer_price() equals
277 // the regular price, so the display must not repeat it in the offer column.
278 $offer_num = self::offer_price( $regular, $selling, $discount_type, $discount_value );
279 $has_offer = '' !== $regular && $offer_num < (float) $regular;
280
281 return array(
282 'relation_id' => null === $relation_id ? (int) $relation['id'] : (int) $relation_id,
283 'plan_id' => (int) $plan['id'],
284 'vid' => (int) $vid,
285 'term' => $plan['title'],
286 'regular' => '' !== $regular ? self::money( (float) $regular ) : '-',
287 'offer' => self::money( $offer_num ),
288 'has_offer' => $has_offer,
289 'regular_raw' => $regular,
290 'offer_raw' => $selling,
291 'exclude' => ! empty( $relation['exclude'] ),
292 );
293 }
294
295 /**
296 * Compute the offer price: (sale ?? regular) minus the discount.
297 *
298 * @param string $regular Regular price.
299 * @param string $selling Sale price (may be empty).
300 * @param string $discount_type percentage|fixed.
301 * @param string $discount_value Discount amount.
302 *
303 * @return float
304 */
305 public static function offer_price( $regular, $selling, $discount_type, $discount_value ) {
306 $base = '' !== $selling ? (float) $selling : (float) $regular;
307 $discount = (float) $discount_value;
308
309 if ( 'percentage' === $discount_type ) {
310 $base -= $base * ( $discount / 100 );
311 } else {
312 $base -= $discount;
313 }
314
315 return max( 0, $base );
316 }
317
318 /**
319 * The term meta parts (billing breakdown + chips) for display under a plan
320 * name. Same set the Plans tab shows. Accepts a raw plan row.
321 *
322 * @param array $plan Plan term row (from PlanRepository::get_plans()).
323 *
324 * @return array<int,string>
325 */
326 public static function term_meta( $plan ) {
327 return array_merge( array( self::breakdown( $plan ) ), self::term_chips( $plan ) );
328 }
329
330 /**
331 * Build the short info chips for a term (free trial, signup fee, expiry).
332 *
333 * @param array $plan Plan term row.
334 *
335 * @return array<int,string>
336 */
337 protected static function term_chips( $plan ) {
338 $chips = array();
339
340 $installments = isset( $plan['data']['installment_count'] ) ? (int) $plan['data']['installment_count'] : 0;
341 if ( $installments > 1 ) {
342 $chips[] = sprintf(
343 /* translators: %d: number of installment payments. */
344 _n( '%d payment', '%d payments', $installments, 'subscription' ),
345 $installments
346 );
347 }
348
349 $trial_days = (int) ( $plan['free_trial'] ?? 0 );
350 if ( $trial_days > 0 ) {
351 $trial_unit = isset( $plan['data']['free_trial_interval'] ) ? (string) $plan['data']['free_trial_interval'] : 'day';
352 $chips[] = sprintf(
353 /* translators: 1: number, 2: unit (day/week/month/year). */
354 __( '%1$d-%2$s free trial', 'subscription' ),
355 $trial_days,
356 $trial_unit
357 );
358 }
359
360 $signup_fee = isset( $plan['signup_fee']['amount'] ) ? (float) $plan['signup_fee']['amount'] : 0.0;
361 if ( $signup_fee > 0 ) {
362 /* translators: %s: formatted signup fee amount. */
363 $chips[] = sprintf( __( 'Signup fee: %s', 'subscription' ), self::money( $signup_fee ) );
364 }
365
366 $length = (int) ( $plan['billing_length'] ?? 0 );
367 if ( $length > 0 ) {
368 $chips[] = sprintf(
369 /* translators: %d: number of billing cycles. */
370 _n( 'Ends after %d cycle', 'Ends after %d cycles', $length, 'subscription' ),
371 $length
372 );
373 }
374
375 return $chips;
376 }
377
378 /**
379 * Build a term's pricing-breakdown display string.
380 *
381 * @param array $plan Plan term row.
382 *
383 * @return string
384 */
385 protected static function breakdown( $plan ) {
386 if ( ! empty( $plan['data']['pricing_breakdown'] ) ) {
387 return $plan['data']['pricing_breakdown'];
388 }
389
390 $freq = max( 1, (int) $plan['billing_frequency'] );
391 $interval = self::interval_label( (int) $plan['billing_interval'] );
392 $every = 1 === $freq ? strtolower( $interval ) : $freq . ' ' . strtolower( $interval ) . 's';
393
394 /* translators: %s: billing interval, e.g. "month" or "2 weeks". */
395 return sprintf( __( 'Billed every %s', 'subscription' ), $every );
396 }
397
398 /**
399 * Map a billing-interval integer to its label.
400 *
401 * @param int $interval 1=day, 2=week, 3=month, 4=year.
402 *
403 * @return string
404 */
405 public static function interval_label( $interval ) {
406 $labels = array(
407 1 => __( 'Day', 'subscription' ),
408 2 => __( 'Week', 'subscription' ),
409 3 => __( 'Month', 'subscription' ),
410 4 => __( 'Year', 'subscription' ),
411 );
412
413 return $labels[ $interval ] ?? __( 'Month', 'subscription' );
414 }
415
416 /**
417 * Human "x ago" string from a MySQL datetime.
418 *
419 * @param string $datetime MySQL datetime (UTC).
420 *
421 * @return string
422 */
423 protected static function ago( $datetime ) {
424 $ts = strtotime( (string) $datetime );
425
426 if ( ! $ts ) {
427 return '';
428 }
429
430 /* translators: %s: human time difference, e.g. "2 hours". */
431 return sprintf( __( '%s ago', 'subscription' ), human_time_diff( $ts, time() ) );
432 }
433
434 /**
435 * Format an amount as a bare number string (no currency symbol).
436 *
437 * @param float $amount Amount.
438 *
439 * @return string
440 */
441 protected static function amount( $amount ) {
442 return number_format( (float) $amount, 2, '.', '' );
443 }
444
445 /**
446 * The price to offer as a product's one-time purchase price.
447 *
448 * One-time purchase sells the product at its native WooCommerce price, so
449 * the field should open on the price the product already has. A product can
450 * carry an active price without a regular one — set by an import, or by a
451 * one-time save that stored only the offer — and reading just the regular
452 * price showed a blank beside a card header quoting the real price.
453 *
454 * @param \WC_Product|null $product Product or variation.
455 *
456 * @return string
457 */
458 public static function one_time_price( $product ) {
459 if ( ! $product ) {
460 return '';
461 }
462
463 $regular = (string) $product->get_regular_price();
464
465 return '' !== $regular ? $regular : (string) $product->get_price();
466 }
467
468 /**
469 * Format an amount in the store's currency, exactly as WooCommerce does.
470 *
471 * WooCommerce's wc_price() applies the store's symbol, its position (left or
472 * right, with or without a space) and its separators and decimals. Appending the symbol
473 * to a dot-decimal number instead printed "25.00$" on a store set to
474 * "$25.00", and "25.00€" on one set to "25,00 €". Tags are stripped and
475 * entities decoded, so callers keep escaping it as plain text — the same
476 * shape PlanController returns for these prices.
477 *
478 * @param float $amount Amount.
479 *
480 * @return string
481 */
482 public static function money( $amount ) {
483 if ( ! function_exists( 'wc_price' ) ) {
484 return self::amount( $amount );
485 }
486
487 return html_entity_decode( wp_strip_all_tags( wc_price( (float) $amount ) ), ENT_QUOTES, 'UTF-8' );
488 }
489 }
490