| 1 |
<?php |
| 2 |
/** |
| 3 |
* Shared receipt contract row shapes and aggregate rules. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\Services |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Services; |
| 9 |
|
| 10 |
/** Receipt sections accept priced values; builders own order reading and pricing. */ |
| 11 |
final class Receipt_Sections { |
| 12 |
/** |
| 13 |
* Shape one line in live receipt key order, preserving nullable price bases. |
| 14 |
* |
| 15 |
* @param array $line Identity, metadata and incl/excl amount pairs. |
| 16 |
* @param bool $display_incl Whether bare amounts include tax. |
| 17 |
* @return array |
| 18 |
*/ |
| 19 |
public static function line( array $line, bool $display_incl ): array { |
| 20 |
$row = array(); |
| 21 |
foreach ( array( 'key', 'sku', 'name', 'qty', 'qty_refunded' ) as $key ) { |
| 22 |
$row[ $key ] = $line[ $key ]; |
| 23 |
} |
| 24 |
foreach ( array( 'unit_subtotal', 'unit_price', 'line_subtotal', 'discounts', 'line_total' ) as $key ) { |
| 25 |
$row = array_merge( $row, self::triple( $key, $line[ $key ], $display_incl ) ); |
| 26 |
} |
| 27 |
foreach ( array( 'total_refunded', 'taxes', 'meta', 'attributes' ) as $key ) { |
| 28 |
$row[ $key ] = $line[ $key ]; |
| 29 |
} |
| 30 |
foreach ( array( 'regular_price', 'selling_price', 'unit_savings', 'line_regular_total', 'line_selling_total', 'line_savings' ) as $key ) { |
| 31 |
$row = array_merge( $row, self::triple( $key, $line[ $key ], $display_incl ) ); |
| 32 |
} |
| 33 |
$row['savings_in_discounts'] = $line['savings_in_discounts']; |
| 34 |
return $row; |
| 35 |
} |
| 36 |
|
| 37 |
/** |
| 38 |
* Shape a fee row. |
| 39 |
* |
| 40 |
* @param string $label Display label. |
| 41 |
* @param array $total Incl/excl amounts. |
| 42 |
* @param array $taxes Item taxes. |
| 43 |
* @param array $meta Metadata pairs. |
| 44 |
* @param bool $display_incl Whether bare amounts include tax. |
| 45 |
* @return array |
| 46 |
*/ |
| 47 |
public static function fee( string $label, array $total, array $taxes, array $meta, bool $display_incl ): array { |
| 48 |
return array_merge( |
| 49 |
array( 'label' => $label ), |
| 50 |
self::triple( 'total', $total, $display_incl ), |
| 51 |
array( |
| 52 |
'taxes' => $taxes, |
| 53 |
'meta' => $meta, |
| 54 |
) |
| 55 |
); |
| 56 |
} |
| 57 |
|
| 58 |
/** |
| 59 |
* Shape a shipping row. |
| 60 |
* |
| 61 |
* @param string $label Display label. |
| 62 |
* @param string $method_id Shipping method. |
| 63 |
* @param array $total Incl/excl amounts. |
| 64 |
* @param array $taxes Item taxes. |
| 65 |
* @param array $meta Metadata pairs. |
| 66 |
* @param bool $display_incl Whether bare amounts include tax. |
| 67 |
* @return array |
| 68 |
*/ |
| 69 |
public static function shipping( string $label, string $method_id, array $total, array $taxes, array $meta, bool $display_incl ): array { |
| 70 |
return array_merge( |
| 71 |
array( |
| 72 |
'label' => $label, |
| 73 |
'method_id' => $method_id, |
| 74 |
), |
| 75 |
self::triple( 'total', $total, $display_incl ), |
| 76 |
array( |
| 77 |
'taxes' => $taxes, |
| 78 |
'meta' => $meta, |
| 79 |
) |
| 80 |
); |
| 81 |
} |
| 82 |
|
| 83 |
/** |
| 84 |
* Shape a discount row. |
| 85 |
* |
| 86 |
* @param string $label Display label. |
| 87 |
* @param string $code Coupon code. |
| 88 |
* @param string $discount_type Coupon type. |
| 89 |
* @param array $total Incl/excl amounts. |
| 90 |
* @param bool $display_incl Whether bare amounts include tax. |
| 91 |
* @return array |
| 92 |
*/ |
| 93 |
public static function discount( string $label, string $code, string $discount_type, array $total, bool $display_incl ): array { |
| 94 |
return array_merge( |
| 95 |
array( |
| 96 |
'label' => $label, |
| 97 |
'code' => $code, |
| 98 |
'discount_type' => $discount_type, |
| 99 |
), |
| 100 |
self::triple( 'total', $total, $display_incl ) |
| 101 |
); |
| 102 |
} |
| 103 |
|
| 104 |
/** |
| 105 |
* Shape a payment row. |
| 106 |
* |
| 107 |
* @param string $method_id Payment method. |
| 108 |
* @param string $method_title Display title. |
| 109 |
* @param float $amount Paid amount. |
| 110 |
* @param string $transaction_id Transaction reference. |
| 111 |
* @param float $tendered Tendered amount. |
| 112 |
* @param float $change Change returned. |
| 113 |
* @return array |
| 114 |
*/ |
| 115 |
public static function payment( string $method_id, string $method_title, float $amount, string $transaction_id, float $tendered, float $change ): array { |
| 116 |
return array( |
| 117 |
'method_id' => $method_id, |
| 118 |
'method_title' => $method_title, |
| 119 |
'amount' => $amount, |
| 120 |
'transaction_id' => $transaction_id, |
| 121 |
'tendered' => $tendered, |
| 122 |
'change' => $change, |
| 123 |
); |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Shape a tax summary, keeping unknown rates and bases null. |
| 128 |
* |
| 129 |
* @param string $code Rate identifier. |
| 130 |
* @param float $rate Percentage rate. |
| 131 |
* @param string $label Display label. |
| 132 |
* @param bool $compound Whether the rate is compound. |
| 133 |
* @param float|null $taxable_excl Known taxable base, if any. |
| 134 |
* @param float $tax_amount Tax charged. |
| 135 |
* @return array |
| 136 |
*/ |
| 137 |
public static function tax_summary_entry( string $code, float $rate, string $label, bool $compound, ?float $taxable_excl, float $tax_amount ): array { |
| 138 |
return array( |
| 139 |
'code' => $code, |
| 140 |
'rate' => $rate > 0 ? $rate : null, |
| 141 |
'label' => $label, |
| 142 |
'compound' => $compound, |
| 143 |
'taxable_amount_excl' => $taxable_excl, |
| 144 |
'tax_amount' => $tax_amount, |
| 145 |
'taxable_amount_incl' => null !== $taxable_excl ? $taxable_excl + $tax_amount : null, |
| 146 |
); |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Aggregate shaped lines and order-level amounts into receipt totals. |
| 151 |
* |
| 152 |
* @param array $lines Shaped line rows. |
| 153 |
* @param array $amounts Discount pair, tax, inclusive total, paid, change and refund totals. |
| 154 |
* @param bool $display_incl Whether bare amounts include tax. |
| 155 |
* @return array |
| 156 |
*/ |
| 157 |
public static function totals( array $lines, array $amounts, bool $display_incl ): array { |
| 158 |
// Legacy POS lines already include regular-to-selling savings in WooCommerce's |
| 159 |
// discount total. Add only current-shape savings to total_saved to avoid overlap. |
| 160 |
$sale_savings_totals = array( |
| 161 |
'incl' => 0.0, |
| 162 |
'excl' => 0.0, |
| 163 |
); |
| 164 |
$additional_savings = array( |
| 165 |
'incl' => 0.0, |
| 166 |
'excl' => 0.0, |
| 167 |
); |
| 168 |
$savings_complete = array( |
| 169 |
'incl' => true, |
| 170 |
'excl' => true, |
| 171 |
); |
| 172 |
$price_precision = wc_get_price_decimals(); |
| 173 |
foreach ( $lines as $line ) { |
| 174 |
foreach ( array( 'incl', 'excl' ) as $basis ) { |
| 175 |
$key = 'line_savings_' . $basis; |
| 176 |
if ( ! isset( $line[ $key ] ) || ! is_numeric( $line[ $key ] ) ) { |
| 177 |
$savings_complete[ $basis ] = false; |
| 178 |
continue; |
| 179 |
} |
| 180 |
|
| 181 |
$line_savings = (float) $line[ $key ]; |
| 182 |
$sale_savings_totals[ $basis ] += $line_savings; |
| 183 |
if ( empty( $line['savings_in_discounts'] ) ) { |
| 184 |
$subtotal_key = 'line_subtotal_' . $basis; |
| 185 |
$selling_key = 'line_selling_total_' . $basis; |
| 186 |
if ( |
| 187 |
$line_savings > 0.0 |
| 188 |
&& ( |
| 189 |
! isset( $line[ $subtotal_key ], $line[ $selling_key ] ) |
| 190 |
|| round( abs( (float) $line[ $subtotal_key ] - (float) $line[ $selling_key ] ), $price_precision ) > 0.0 |
| 191 |
) |
| 192 |
) { |
| 193 |
$savings_complete[ $basis ] = false; |
| 194 |
continue; |
| 195 |
} |
| 196 |
$additional_savings[ $basis ] += $line_savings; |
| 197 |
} |
| 198 |
} |
| 199 |
} |
| 200 |
|
| 201 |
$total_saved = array( |
| 202 |
'incl' => $savings_complete['incl'] ? $amounts['discount_total']['incl'] + $additional_savings['incl'] : null, |
| 203 |
'excl' => $savings_complete['excl'] ? $amounts['discount_total']['excl'] + $additional_savings['excl'] : null, |
| 204 |
); |
| 205 |
foreach ( array( 'incl', 'excl' ) as $basis ) { |
| 206 |
if ( ! $savings_complete[ $basis ] ) { |
| 207 |
$sale_savings_totals[ $basis ] = null; |
| 208 |
} |
| 209 |
} |
| 210 |
$display_basis = $display_incl ? 'incl' : 'excl'; |
| 211 |
|
| 212 |
$subtotal_excl = array_sum( array_column( $lines, 'line_subtotal_excl' ) ); |
| 213 |
$subtotal_incl = array_sum( array_column( $lines, 'line_subtotal_incl' ) ); |
| 214 |
|
| 215 |
// Item count summaries — useful for packing slips and kitchen tickets |
| 216 |
// where Mustache can't sum/count an array at render time. |
| 217 |
$total_qty = (float) array_sum( array_column( $lines, 'qty' ) ); |
| 218 |
$line_count = \count( $lines ); |
| 219 |
|
| 220 |
$tax_total = $amounts['tax_total']; |
| 221 |
$total = $amounts['total']; |
| 222 |
$total_excl = $total - $tax_total; |
| 223 |
$refund_total = $amounts['refund_total']; |
| 224 |
// Templates render the customer-facing balance after a partial refund. |
| 225 |
// Stays at 0 when nothing was refunded so detailed-receipt's section |
| 226 |
// guard `{{#totals.net_total}}…{{/totals.net_total}}` collapses. |
| 227 |
$net_total = $refund_total > 0 ? max( 0.0, $total - $refund_total ) : 0.0; |
| 228 |
|
| 229 |
return array( |
| 230 |
'subtotal' => $display_incl ? $subtotal_incl : $subtotal_excl, |
| 231 |
'subtotal_incl' => $subtotal_incl, |
| 232 |
'subtotal_excl' => $subtotal_excl, |
| 233 |
'discount_total' => $display_incl ? $amounts['discount_total']['incl'] : $amounts['discount_total']['excl'], |
| 234 |
'discount_total_incl' => $amounts['discount_total']['incl'], |
| 235 |
'discount_total_excl' => $amounts['discount_total']['excl'], |
| 236 |
'sale_savings_total' => $sale_savings_totals[ $display_basis ], |
| 237 |
'sale_savings_total_incl' => $sale_savings_totals['incl'], |
| 238 |
'sale_savings_total_excl' => $sale_savings_totals['excl'], |
| 239 |
'total_saved' => $total_saved[ $display_basis ], |
| 240 |
'total_saved_incl' => $total_saved['incl'], |
| 241 |
'total_saved_excl' => $total_saved['excl'], |
| 242 |
'total_saved_complete' => $savings_complete[ $display_basis ], |
| 243 |
'tax_total' => $tax_total, |
| 244 |
'total' => $display_incl ? $total : $total_excl, |
| 245 |
'total_incl' => $total, |
| 246 |
'total_excl' => $total_excl, |
| 247 |
'paid_total' => $amounts['paid_total'], |
| 248 |
'change_total' => $amounts['change_total'], |
| 249 |
'refund_total' => $refund_total, |
| 250 |
'net_total' => $net_total, |
| 251 |
'total_qty' => $total_qty, |
| 252 |
'line_count' => $line_count, |
| 253 |
); |
| 254 |
} |
| 255 |
|
| 256 |
/** |
| 257 |
* Attach labels: explicit label → scoped type key → scoped other fallback. |
| 258 |
* |
| 259 |
* @param array $tax_ids Tax ID rows. |
| 260 |
* @param string $scope Customer or store. |
| 261 |
* @param string $locale Receipt locale. |
| 262 |
* @return array |
| 263 |
*/ |
| 264 |
public static function label_tax_ids( array $tax_ids, string $scope, string $locale = '' ): array { |
| 265 |
$labels = Receipt_I18n_Labels::get_labels( $locale ); |
| 266 |
$prefix = $scope . '_tax_id_label_'; |
| 267 |
|
| 268 |
return array_map( |
| 269 |
static function ( array $tax_id ) use ( $labels, $prefix ): array { |
| 270 |
if ( ! empty( $tax_id['label'] ) ) { |
| 271 |
return $tax_id; |
| 272 |
} |
| 273 |
|
| 274 |
$type = isset( $tax_id['type'] ) ? (string) $tax_id['type'] : 'other'; |
| 275 |
$key = $prefix . $type; |
| 276 |
$tax_id['label'] = $labels[ $key ] ?? $labels[ $prefix . 'other' ]; |
| 277 |
|
| 278 |
return $tax_id; |
| 279 |
}, |
| 280 |
$tax_ids |
| 281 |
); |
| 282 |
} |
| 283 |
|
| 284 |
/** |
| 285 |
* Resolve non-empty variation attributes to tag-free label/value pairs. |
| 286 |
* |
| 287 |
* @param \WC_Product_Variation $variation Variation product. |
| 288 |
* @return array |
| 289 |
*/ |
| 290 |
public static function variation_attribute_pairs( \WC_Product_Variation $variation ): array { |
| 291 |
$pairs = array(); |
| 292 |
foreach ( $variation->get_variation_attributes() as $attribute_key => $attribute_value ) { |
| 293 |
if ( '' === (string) $attribute_value ) { |
| 294 |
continue; |
| 295 |
} |
| 296 |
|
| 297 |
$taxonomy = preg_replace( '/^attribute_/', '', (string) $attribute_key ); |
| 298 |
$value = $variation->get_attribute( $taxonomy ); |
| 299 |
$pairs[] = array( |
| 300 |
'key' => wp_strip_all_tags( wc_attribute_label( $taxonomy, $variation ) ), |
| 301 |
'value' => wp_strip_all_tags( '' !== $value ? $value : (string) $attribute_value ), |
| 302 |
); |
| 303 |
} |
| 304 |
|
| 305 |
return $pairs; |
| 306 |
} |
| 307 |
|
| 308 |
/** |
| 309 |
* Map a basis pair to its display value and two explicit values. |
| 310 |
* |
| 311 |
* @param string $key Bare field name. |
| 312 |
* @param array $pair Nullable incl/excl amounts. |
| 313 |
* @param bool $display_incl Whether the bare amount includes tax. |
| 314 |
* @return array |
| 315 |
*/ |
| 316 |
private static function triple( string $key, array $pair, bool $display_incl ): array { |
| 317 |
return array( |
| 318 |
$key => $pair[ $display_incl ? 'incl' : 'excl' ], |
| 319 |
$key . '_incl' => $pair['incl'], |
| 320 |
$key . '_excl' => $pair['excl'], |
| 321 |
); |
| 322 |
} |
| 323 |
} |
| 324 |
|