PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / trunk
Subscriptions for WooCommerce with Stripe Recurring Payments vtrunk
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 1.4.1 1.4.2 1.5.0 1.5.1 All 61 releases
subscription / includes / Admin / PlanPresenter.php

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

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