| 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 |
|