Templates
3 months ago
ReceiptRenderingEngine.php
2 months ago
ReceiptRenderingRestController.php
1 year ago
ReceiptRenderingEngine.php
438 lines
| 1 | <?php |
| 2 | |
| 3 | namespace Automattic\WooCommerce\Internal\ReceiptRendering; |
| 4 | |
| 5 | use Automattic\WooCommerce\Enums\OrderItemType; |
| 6 | use Automattic\WooCommerce\Internal\Orders\PaymentInfo; |
| 7 | use Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine; |
| 8 | use Automattic\WooCommerce\Proxies\LegacyProxy; |
| 9 | use Automattic\WooCommerce\Utilities\ArrayUtil; |
| 10 | use Exception; |
| 11 | use WC_Abstract_Order; |
| 12 | |
| 13 | /** |
| 14 | * This class generates printable order receipts as transient files (see src/Internal/TransientFiles). |
| 15 | * The template for the receipt is Templates/order-receipt.php, it uses the variables returned as array keys |
| 16 | * 'get_order_data'. |
| 17 | * |
| 18 | * When a receipt is generated for an order with 'generate_receipt' the receipt file name is stored as order meta |
| 19 | * (see RECEIPT_FILE_NAME_META_KEY) for later retrieval with 'get_existing_receipt'. Beware! The files pointed |
| 20 | * by such meta keys could have expired and thus no longer exist. 'get_existing_receipt' will appropriately return null |
| 21 | * if the meta entry exists but the file doesn't. |
| 22 | */ |
| 23 | class ReceiptRenderingEngine { |
| 24 | |
| 25 | private const FONT_SIZE = 12; |
| 26 | |
| 27 | private const LINE_HEIGHT = self::FONT_SIZE * 1.5; |
| 28 | |
| 29 | private const ICON_HEIGHT = self::LINE_HEIGHT; |
| 30 | |
| 31 | private const ICON_WIDTH = self::ICON_HEIGHT * ( 4 / 3 ); |
| 32 | |
| 33 | private const MARGIN = 16; |
| 34 | |
| 35 | private const TITLE_FONT_SIZE = 24; |
| 36 | |
| 37 | private const FOOTER_FONT_SIZE = 10; |
| 38 | |
| 39 | /** |
| 40 | * This array must contain all the names of the files in the CardIcons directory (without extension), |
| 41 | * except 'unknown'. |
| 42 | */ |
| 43 | private const KNOWN_CARD_TYPES = array( 'amex', 'diners', 'discover', 'interac', 'jcb', 'mastercard', 'visa' ); |
| 44 | |
| 45 | /** |
| 46 | * Order meta key that stores the file name of the last generated receipt. |
| 47 | */ |
| 48 | public const RECEIPT_FILE_NAME_META_KEY = '_receipt_file_name'; |
| 49 | |
| 50 | /** |
| 51 | * The instance of TransientFilesEngine to use. |
| 52 | * |
| 53 | * @var TransientFilesEngine |
| 54 | */ |
| 55 | private $transient_files_engine; |
| 56 | |
| 57 | /** |
| 58 | * The instance of LegacyProxy to use. |
| 59 | * |
| 60 | * @var LegacyProxy |
| 61 | */ |
| 62 | private $legacy_proxy; |
| 63 | |
| 64 | /** |
| 65 | * Initializes the class. |
| 66 | * |
| 67 | * @param TransientFilesEngine $transient_files_engine The instance of TransientFilesEngine to use. |
| 68 | * @param LegacyProxy $legacy_proxy The instance of LegacyProxy to use. |
| 69 | * @internal |
| 70 | */ |
| 71 | final public function init( TransientFilesEngine $transient_files_engine, LegacyProxy $legacy_proxy ) { |
| 72 | $this->transient_files_engine = $transient_files_engine; |
| 73 | $this->legacy_proxy = $legacy_proxy; |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Get the (transient) file name of the receipt for an order, creating a new file if necessary. |
| 78 | * |
| 79 | * If $force_new is false, and a receipt file for the order already exists (as pointed by order meta key |
| 80 | * RECEIPT_FILE_NAME_META_KEY), then the name of the already existing receipt file is returned. |
| 81 | * |
| 82 | * If $force_new is true, OR if it's false but no receipt file for the order exists (no order meta with key |
| 83 | * RECEIPT_FILE_NAME_META_KEY exists, OR it exists but the file it points to doesn't), then a new receipt |
| 84 | * transient file is created with the supplied expiration date (defaulting to "tomorrow"), and the new file name |
| 85 | * is stored as order meta with the key RECEIPT_FILE_NAME_META_KEY. |
| 86 | * |
| 87 | * @param int|WC_Abstract_Order $order The order object or order id to get the receipt for. |
| 88 | * @param string|int|null $expiration_date GMT expiration date formatted as yyyy-mm-dd, or as a timestamp, or null for "tomorrow". |
| 89 | * @param bool $force_new If true, creates a new receipt file even if one already exists for the order. |
| 90 | * @return string|null The file name of the new or already existing receipt file, null if an order id is passed and the order doesn't exist. |
| 91 | * @throws InvalidArgumentException Invalid expiration date (wrongly formatted, or it's a date in the past). |
| 92 | * @throws Exception The directory to store the file doesn't exist and can't be created. |
| 93 | */ |
| 94 | public function generate_receipt( $order, $expiration_date = null, bool $force_new = false ): ?string { |
| 95 | if ( ! $order instanceof WC_Abstract_Order ) { |
| 96 | $order = wc_get_order( $order ); |
| 97 | if ( false === $order ) { |
| 98 | return null; |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | if ( ! $force_new ) { |
| 103 | $existing_receipt_filename = $this->get_existing_receipt( $order ); |
| 104 | if ( ! is_null( $existing_receipt_filename ) ) { |
| 105 | return $existing_receipt_filename; |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | $expiration_date ??= |
| 110 | $this->legacy_proxy->call_function( |
| 111 | 'gmdate', |
| 112 | 'Y-m-d', |
| 113 | $this->legacy_proxy->call_function( |
| 114 | 'strtotime', |
| 115 | '+1 days' |
| 116 | ) |
| 117 | ); |
| 118 | |
| 119 | /** |
| 120 | * Filter to customize the set of data that is used to render the receipt. |
| 121 | * The formatted line items aren't included, use the woocommerce_printable_order_receipt_formatted_line_item |
| 122 | * filter to customize those. |
| 123 | * |
| 124 | * See the value returned by the 'get_order_data' and 'get_woo_pay_data' methods for a reference of |
| 125 | * the structure of the data. |
| 126 | * |
| 127 | * See the template file, Templates/order-receipt.php, for reference on how the data is used. |
| 128 | * |
| 129 | * @param array $data The original set of data. |
| 130 | * @param WC_Abstract_Order $order The order for which the receipt is being generated. |
| 131 | * @returns array The updated set of data. |
| 132 | * |
| 133 | * @since 9.0.0 |
| 134 | */ |
| 135 | $data = apply_filters( 'woocommerce_printable_order_receipt_data', $this->get_order_data( $order ), $order ); |
| 136 | |
| 137 | $formatted_line_items = array(); |
| 138 | $row_index = 0; |
| 139 | foreach ( $data['line_items'] as $line_item_data ) { |
| 140 | $quantity_data = isset( $line_item_data['quantity'] ) ? " × {$line_item_data['quantity']}" : ''; |
| 141 | $line_item_display_data = array( |
| 142 | 'inner_html' => "<td>{$line_item_data['title']}$quantity_data</td><td>{$line_item_data['amount']}</td>", |
| 143 | 'tr_attributes' => array(), |
| 144 | 'row_index' => $row_index++, |
| 145 | ); |
| 146 | |
| 147 | /** |
| 148 | * Filter to customize the HTML that gets rendered for each order line item in the receipt. |
| 149 | * |
| 150 | * $line_item_display_data will be passed (and must be returned) with the following keys: |
| 151 | * |
| 152 | * - inner_html: the HTML text that will go inside a <tr> element, note that |
| 153 | * wp_kses_post will be applied to this text before actual rendering. |
| 154 | * - tr_attributes: attributes (e.g. 'class', 'data', 'style') that will be applied to the <tr> element, |
| 155 | * as an associative array of attribute name => value. |
| 156 | * - row_index: a number that starts at 0 and increases by one for each processed line item. |
| 157 | * |
| 158 | * $line_item_data will contain the following keys: |
| 159 | * |
| 160 | * - type: One of 'product', 'subtotal', 'discount', 'fee', 'shipping_total', 'taxes_total', 'amount_paid' |
| 161 | * - title |
| 162 | * - amount (formatted with wc_price) |
| 163 | * - item (only when type is 'product'), and instance of WC_Order_Item |
| 164 | * - quantity (only when type is 'product') |
| 165 | * |
| 166 | * @param string $line_item_display_data Data to use to generate the HTML table row to be rendered for the line item. |
| 167 | * @param array $line_item_data The relevant data for the line item for which the HTML table row is being generated. |
| 168 | * @param WC_Abstract_Order $order The order for which the receipt is being generated. |
| 169 | * @return string The actual data to use to generate the HTML for the line item. |
| 170 | * |
| 171 | * @since 9.0.0 |
| 172 | */ |
| 173 | $line_item_display_data = apply_filters( 'woocommerce_printable_order_receipt_line_item_display_data', $line_item_display_data, $line_item_data, $order ); |
| 174 | $attributes = ''; |
| 175 | foreach ( $line_item_display_data['tr_attributes'] as $attribute_name => $attribute_value ) { |
| 176 | $attribute_value = esc_attr( $attribute_value ); |
| 177 | $attributes .= " $attribute_name=\"$attribute_value\""; |
| 178 | } |
| 179 | $formatted_line_items[] = wp_kses_post( "<tr$attributes>{$line_item_display_data['inner_html']}</tr>" ); |
| 180 | } |
| 181 | $data['formatted_line_items'] = $formatted_line_items; |
| 182 | |
| 183 | ob_start(); |
| 184 | $css = include __DIR__ . '/Templates/order-receipt-css.php'; |
| 185 | $css = ob_get_contents(); |
| 186 | ob_end_clean(); |
| 187 | |
| 188 | /** |
| 189 | * Filter to customize the CSS styles used to render the receipt. |
| 190 | * |
| 191 | * See Templates/order-receipt.php for guidance on the existing HTMl elements and their ids. |
| 192 | * See Templates/order-receipt-css.php for the original CSS styles. |
| 193 | * |
| 194 | * @param string $css The original CSS styles to use. |
| 195 | * @param WC_Abstract_Order $order The order for which the receipt is being generated. |
| 196 | * @return string The actual CSS styles that will be used. |
| 197 | * |
| 198 | * @since 9.0.0 |
| 199 | */ |
| 200 | $data['css'] = apply_filters( 'woocommerce_printable_order_receipt_css', $css, $order ); |
| 201 | |
| 202 | $default_template_path = __DIR__ . '/Templates/order-receipt.php'; |
| 203 | |
| 204 | /** |
| 205 | * Filter the order receipt template path. |
| 206 | * |
| 207 | * @since 9.2.0 |
| 208 | * @hook wc_get_template |
| 209 | * @param string $template The template path. |
| 210 | * @param string $template_name The template name. |
| 211 | * @param array $args The available data for the template. |
| 212 | * @param string $template_path The template path. |
| 213 | * @param string $default_path The default template path. |
| 214 | */ |
| 215 | $template_path = apply_filters( |
| 216 | 'wc_get_template', |
| 217 | $default_template_path, |
| 218 | 'ReceiptRendering/order-receipt.php', |
| 219 | $data, |
| 220 | $default_template_path, |
| 221 | $default_template_path |
| 222 | ); |
| 223 | |
| 224 | if ( ! file_exists( $template_path ) ) { |
| 225 | $template_path = $default_template_path; |
| 226 | } |
| 227 | |
| 228 | ob_start(); |
| 229 | include $template_path; |
| 230 | $rendered_template = ob_get_contents(); |
| 231 | ob_end_clean(); |
| 232 | |
| 233 | $file_name = $this->transient_files_engine->create_transient_file( $rendered_template, $expiration_date ); |
| 234 | |
| 235 | $order->update_meta_data( self::RECEIPT_FILE_NAME_META_KEY, $file_name ); |
| 236 | $order->save_meta_data(); |
| 237 | |
| 238 | return $file_name; |
| 239 | } |
| 240 | |
| 241 | /** |
| 242 | * Get the file name of an existing receipt file for an order. |
| 243 | * |
| 244 | * A receipt is considered to be available for the order if there's an order meta entry with key |
| 245 | * RECEIPT_FILE_NAME_META_KEY AND the transient file it points to exists AND it has not expired. |
| 246 | * |
| 247 | * @param WC_Abstract_Order $order The order object or order id to get the receipt for. |
| 248 | * @return string|null The receipt file name, or null if no receipt is currently available for the order. |
| 249 | * @throws Exception Thrown if a wrong file path is passed. |
| 250 | */ |
| 251 | public function get_existing_receipt( $order ): ?string { |
| 252 | if ( ! $order instanceof WC_Abstract_Order ) { |
| 253 | $order = wc_get_order( $order ); |
| 254 | if ( false === $order ) { |
| 255 | return null; |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | $existing_receipt_filename = $order->get_meta( self::RECEIPT_FILE_NAME_META_KEY, true ); |
| 260 | |
| 261 | if ( '' === $existing_receipt_filename ) { |
| 262 | return null; |
| 263 | } |
| 264 | |
| 265 | $file_path = $this->transient_files_engine->get_transient_file_path( $existing_receipt_filename ); |
| 266 | if ( is_null( $file_path ) ) { |
| 267 | return null; |
| 268 | } |
| 269 | |
| 270 | return $this->transient_files_engine->file_has_expired( $file_path ) ? null : $existing_receipt_filename; |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * Get the order data that the receipt template will use. |
| 275 | * |
| 276 | * @param WC_Abstract_Order $order The order to get the data from. |
| 277 | * @return array The order data as an associative array. |
| 278 | */ |
| 279 | private function get_order_data( WC_Abstract_Order $order ): array { |
| 280 | $store_name = get_bloginfo( 'name' ); |
| 281 | if ( $store_name ) { |
| 282 | /* translators: %s = store name */ |
| 283 | $receipt_title = sprintf( __( 'Receipt from %s', 'woocommerce' ), $store_name ); |
| 284 | } else { |
| 285 | $receipt_title = __( 'Receipt', 'woocommerce' ); |
| 286 | } |
| 287 | |
| 288 | $order_id = $order->get_id(); |
| 289 | if ( $order_id ) { |
| 290 | /* translators: %d = order id */ |
| 291 | $summary_title = sprintf( __( 'Summary: Order #%d', 'woocommerce' ), $order->get_id() ); |
| 292 | } else { |
| 293 | $summary_title = __( 'Summary', 'woocommerce' ); |
| 294 | } |
| 295 | |
| 296 | $get_price_args = array( 'currency' => $order->get_currency() ); |
| 297 | |
| 298 | $line_items_info = array(); |
| 299 | $line_items = $order->get_items( OrderItemType::LINE_ITEM ); |
| 300 | foreach ( $line_items as $line_item ) { |
| 301 | $line_item_product = $line_item->get_product(); |
| 302 | if ( false === $line_item_product ) { |
| 303 | $line_item_title = $line_item->get_name(); |
| 304 | } else { |
| 305 | $line_item_title = |
| 306 | ( $line_item_product instanceof \WC_Product_Variation ) ? |
| 307 | ( wc_get_product( $line_item_product->get_parent_id() )->get_name() ) . '. ' . $line_item_product->get_attribute_summary() : |
| 308 | $line_item_product->get_name(); |
| 309 | } |
| 310 | $line_items_info[] = array( |
| 311 | 'type' => 'product', |
| 312 | 'item' => $line_item, |
| 313 | 'title' => wp_kses( $line_item_title, array() ), |
| 314 | 'quantity' => $line_item->get_quantity(), |
| 315 | 'amount' => wc_price( $line_item->get_subtotal(), $get_price_args ), |
| 316 | ); |
| 317 | } |
| 318 | |
| 319 | $line_items_info[] = array( |
| 320 | 'type' => 'subtotal', |
| 321 | 'title' => __( 'Subtotal', 'woocommerce' ), |
| 322 | 'amount' => wc_price( $order->get_subtotal(), $get_price_args ), |
| 323 | ); |
| 324 | |
| 325 | $coupon_names = ArrayUtil::select( $order->get_coupons(), 'get_name', ArrayUtil::SELECT_BY_OBJECT_METHOD ); |
| 326 | if ( ! empty( $coupon_names ) ) { |
| 327 | $line_items_info[] = array( |
| 328 | 'type' => 'discount', |
| 329 | /* translators: %s = comma-separated list of coupon codes */ |
| 330 | 'title' => sprintf( __( 'Discount (%s)', 'woocommerce' ), join( ', ', $coupon_names ) ), |
| 331 | 'amount' => wc_price( -$order->get_total_discount(), $get_price_args ), |
| 332 | ); |
| 333 | } |
| 334 | |
| 335 | foreach ( $order->get_fees() as $fee ) { |
| 336 | $name = $fee->get_name(); |
| 337 | $line_items_info[] = array( |
| 338 | 'type' => OrderItemType::FEE, |
| 339 | 'title' => '' === $name ? __( 'Fee', 'woocommerce' ) : $name, |
| 340 | 'amount' => wc_price( $fee->get_total(), $get_price_args ), |
| 341 | ); |
| 342 | } |
| 343 | |
| 344 | $shipping_total = (float) $order->get_shipping_total(); |
| 345 | if ( $shipping_total ) { |
| 346 | $line_items_info[] = array( |
| 347 | 'type' => 'shipping_total', |
| 348 | 'title' => __( 'Shipping', 'woocommerce' ), |
| 349 | 'amount' => wc_price( $order->get_shipping_total(), $get_price_args ), |
| 350 | ); |
| 351 | } |
| 352 | |
| 353 | $total_taxes = 0; |
| 354 | foreach ( $order->get_taxes() as $tax ) { |
| 355 | $total_taxes += (float) $tax->get_tax_total() + (float) $tax->get_shipping_tax_total(); |
| 356 | } |
| 357 | |
| 358 | if ( $total_taxes ) { |
| 359 | $line_items_info[] = array( |
| 360 | 'type' => 'taxes_total', |
| 361 | 'title' => __( 'Taxes', 'woocommerce' ), |
| 362 | 'amount' => wc_price( $total_taxes, $get_price_args ), |
| 363 | ); |
| 364 | } |
| 365 | |
| 366 | $is_order_failed = $order->has_status( 'failed' ); |
| 367 | |
| 368 | $line_items_info[] = array( |
| 369 | 'type' => 'amount_paid', |
| 370 | 'title' => $is_order_failed ? __( 'Amount', 'woocommerce' ) : __( 'Amount Paid', 'woocommerce' ), |
| 371 | 'amount' => wc_price( $order->get_total(), $get_price_args ), |
| 372 | ); |
| 373 | |
| 374 | $payment_info = $this->get_woo_pay_data( $order ); |
| 375 | |
| 376 | return array( |
| 377 | 'order' => $order, |
| 378 | 'constants' => array( |
| 379 | 'font_size' => self::FONT_SIZE, |
| 380 | 'margin' => self::MARGIN, |
| 381 | 'title_font_size' => self::TITLE_FONT_SIZE, |
| 382 | 'footer_font_size' => self::FOOTER_FONT_SIZE, |
| 383 | 'line_height' => self::LINE_HEIGHT, |
| 384 | 'icon_height' => self::ICON_HEIGHT, |
| 385 | 'icon_width' => self::ICON_WIDTH, |
| 386 | ), |
| 387 | 'texts' => array( |
| 388 | 'receipt_title' => $receipt_title, |
| 389 | 'amount_paid_section_title' => $is_order_failed ? __( 'Order Total', 'woocommerce' ) : __( 'Amount Paid', 'woocommerce' ), |
| 390 | 'date_paid_section_title' => $is_order_failed ? __( 'Order Date', 'woocommerce' ) : __( 'Date Paid', 'woocommerce' ), |
| 391 | 'payment_method_section_title' => __( 'Payment method', 'woocommerce' ), |
| 392 | 'payment_status_section_title' => __( 'Payment status', 'woocommerce' ), |
| 393 | 'payment_status' => $is_order_failed ? __( 'Failed', 'woocommerce' ) : __( 'Success', 'woocommerce' ), |
| 394 | 'summary_section_title' => $summary_title, |
| 395 | 'order_notes_section_title' => __( 'Notes', 'woocommerce' ), |
| 396 | 'app_name' => __( 'Application Name', 'woocommerce' ), |
| 397 | 'aid' => __( 'AID', 'woocommerce' ), |
| 398 | 'account_type' => __( 'Account Type', 'woocommerce' ), |
| 399 | ), |
| 400 | 'formatted_amount' => wc_price( $order->get_total(), $get_price_args ), |
| 401 | 'formatted_date' => wc_format_datetime( $order->get_date_paid() ?? $order->get_date_created() ), |
| 402 | 'line_items' => $line_items_info, |
| 403 | 'payment_method' => $order->get_payment_method_title(), |
| 404 | 'show_payment_method_title' => empty( $payment_info['card_last4'] ) && empty( $payment_info['brand'] ), |
| 405 | 'notes' => array_map( 'get_comment_text', $order->get_customer_order_notes() ), |
| 406 | 'payment_info' => $payment_info, |
| 407 | ); |
| 408 | } |
| 409 | |
| 410 | /** |
| 411 | * Get the order data related to WooCommerce Payments. |
| 412 | * |
| 413 | * It will return null if any of these is true: |
| 414 | * |
| 415 | * - Payment method is not "woocommerce_payments". |
| 416 | * - WooCommerce Payments is not installed. |
| 417 | * - No intent id is stored for the order. |
| 418 | * - Retrieving the payment information from Stripe API (providing the intent id) fails. |
| 419 | * - The received data set doesn't contain the expected information. |
| 420 | * |
| 421 | * @param WC_Abstract_Order $order The order to get the data from. |
| 422 | * @return array|null An array of payment information for the order, or null if not available. |
| 423 | */ |
| 424 | private function get_woo_pay_data( WC_Abstract_Order $order ): ?array { |
| 425 | $card_info = PaymentInfo::get_card_info( $order ); |
| 426 | |
| 427 | if ( empty( $card_info ) ) { |
| 428 | return null; |
| 429 | } |
| 430 | |
| 431 | // Backcompat for custom templates. |
| 432 | $card_info['card_icon'] = $card_info['icon']; |
| 433 | $card_info['card_last4'] = $card_info['last4']; |
| 434 | |
| 435 | return $card_info; |
| 436 | } |
| 437 | } |
| 438 |