PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 2.2.0
Subscriptions for WooCommerce with Stripe Recurring Payments v2.2.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.2.0, at includes/Admin/PlanPresenter.php

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