From 89c6fbbb7c2b3bef9b849f465375360f448da6fe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?N=C3=A9stor=20Soriano?=
Date: Mon, 19 Feb 2024 12:03:46 +0100
Subject: [PATCH] Add the Receipts Rendering Engine (#43502)
---
plugins/woocommerce/changelog/pr-43502 | 4 +
.../woocommerce/includes/class-wc-install.php | 10 +
.../woocommerce/includes/rest-api/Server.php | 6 +-
.../includes/wc-update-functions.php | 18 +
.../EnginesServiceProvider.php | 7 +-
.../ReceiptRendering/CardIcons/amex.svg | 14 +
.../ReceiptRendering/CardIcons/diners.svg | 15 +
.../ReceiptRendering/CardIcons/discover.svg | 14 +
.../ReceiptRendering/CardIcons/interac.svg | 1 +
.../ReceiptRendering/CardIcons/jcb.svg | 29 ++
.../ReceiptRendering/CardIcons/mastercard.svg | 31 ++
.../ReceiptRendering/CardIcons/unknown.svg | 1 +
.../ReceiptRendering/CardIcons/visa.svg | 17 +
.../ReceiptRenderingEngine.php | 350 ++++++++++++++++++
.../ReceiptRenderingRestController.php | 203 ++++++++++
.../Templates/order-receipt.php | 106 ++++++
.../src/Internal/RestApiControllerBase.php | 233 ++++++++++++
.../TransientFiles/TransientFilesEngine.php | 47 ++-
.../ReceiptRenderingEngineTest.php | 255 +++++++++++++
.../TransientFilesEngineTest.php | 59 +++
20 files changed, 1410 insertions(+), 10 deletions(-)
create mode 100644 plugins/woocommerce/changelog/pr-43502
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/amex.svg
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/diners.svg
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/discover.svg
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/interac.svg
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/jcb.svg
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/mastercard.svg
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/unknown.svg
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/visa.svg
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingRestController.php
create mode 100644 plugins/woocommerce/src/Internal/ReceiptRendering/Templates/order-receipt.php
create mode 100644 plugins/woocommerce/src/Internal/RestApiControllerBase.php
create mode 100644 plugins/woocommerce/tests/php/src/Internal/ReceiptRendering/ReceiptRenderingEngineTest.php
diff --git a/plugins/woocommerce/changelog/pr-43502 b/plugins/woocommerce/changelog/pr-43502
new file mode 100644
index 00000000000..7f0a562057b
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-43502
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the receipts rendering engine
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 8ac42cdea6f..d2f40c04d2c 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -8,6 +8,7 @@
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\Notes\Notes;
+use Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine;
use Automattic\WooCommerce\Internal\DataStores\Orders\{ CustomOrdersTableController, DataSynchronizer, OrdersTableDataStore };
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
@@ -243,6 +244,9 @@ class WC_Install {
'8.6.0' => array(
'wc_update_860_remove_recommended_marketing_plugins_transient',
),
+ '8.7.0' => array(
+ 'wc_update_870_prevent_listing_of_transient_files_directory',
+ ),
);
/**
@@ -456,6 +460,11 @@ class WC_Install {
// plugin version update. We base plugin age off of this value.
add_option( 'woocommerce_admin_install_timestamp', time() );
+ // Force a flush of rewrite rules even if the corresponding hook isn't initialized yet.
+ if ( ! has_action( 'woocommerce_flush_rewrite_rules' ) ) {
+ flush_rewrite_rules();
+ }
+
/**
* Flush the rewrite rules after install or update.
*
@@ -557,6 +566,7 @@ class WC_Install {
WC()->query->add_endpoints();
WC_API::add_endpoint();
WC_Auth::add_endpoint();
+ TransientFilesEngine::add_endpoint();
}
/**
diff --git a/plugins/woocommerce/includes/rest-api/Server.php b/plugins/woocommerce/includes/rest-api/Server.php
index e1ba98a00e0..cfe256ba012 100644
--- a/plugins/woocommerce/includes/rest-api/Server.php
+++ b/plugins/woocommerce/includes/rest-api/Server.php
@@ -37,9 +37,13 @@ class Server {
* Register REST API routes.
*/
public function register_rest_routes() {
+ $container = wc_get_container();
foreach ( $this->get_rest_namespaces() as $namespace => $controllers ) {
foreach ( $controllers as $controller_name => $controller_class ) {
- $this->controllers[ $namespace ][ $controller_name ] = new $controller_class();
+ $this->controllers[ $namespace ][ $controller_name ] =
+ 0 === strpos( $controller_class, 'WC_REST_' ) ?
+ new $controller_class() :
+ $container->get( $controller_class );
$this->controllers[ $namespace ][ $controller_name ]->register_routes();
}
}
diff --git a/plugins/woocommerce/includes/wc-update-functions.php b/plugins/woocommerce/includes/wc-update-functions.php
index 110bcf26e33..c6776671b30 100644
--- a/plugins/woocommerce/includes/wc-update-functions.php
+++ b/plugins/woocommerce/includes/wc-update-functions.php
@@ -2649,3 +2649,21 @@ LIMIT 250
function wc_update_860_remove_recommended_marketing_plugins_transient() {
delete_transient( 'wc_marketing_recommended_plugins' );
}
+
+/**
+ * Create an .htaccess file and an empty index.html file to prevent listing of the default transient files directory,
+ * if the directory exists.
+ */
+function wc_update_870_prevent_listing_of_transient_files_directory() {
+ global $wp_filesystem;
+
+ $default_transient_files_dir = untrailingslashit( wp_upload_dir()['basedir'] ) . '/woocommerce_transient_files';
+ if ( ! is_dir( $default_transient_files_dir ) ) {
+ return;
+ }
+
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ \WP_Filesystem();
+ $wp_filesystem->put_contents( $default_transient_files_dir . '/.htaccess', 'deny from all' );
+ $wp_filesystem->put_contents( $default_transient_files_dir . '/index.html', '' );
+}
diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/EnginesServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/EnginesServiceProvider.php
index 22859291cec..5b91c652648 100644
--- a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/EnginesServiceProvider.php
+++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/EnginesServiceProvider.php
@@ -5,7 +5,8 @@
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
-use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
+use Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingEngine;
+use Automattic\WooCommerce\Internal\ReceiptRendering\ReceiptRenderingRestController;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Internal\TransientFiles\TransientFilesEngine;
@@ -21,6 +22,8 @@ class EnginesServiceProvider extends AbstractInterfaceServiceProvider {
*/
protected $provides = array(
TransientFilesEngine::class,
+ ReceiptRenderingEngine::class,
+ ReceiptRenderingRestController::class,
);
/**
@@ -28,5 +31,7 @@ class EnginesServiceProvider extends AbstractInterfaceServiceProvider {
*/
public function register() {
$this->share_with_implements_tags( TransientFilesEngine::class )->addArgument( LegacyProxy::class );
+ $this->share( ReceiptRenderingEngine::class )->addArguments( array( TransientFilesEngine::class, LegacyProxy::class ) );
+ $this->share_with_implements_tags( ReceiptRenderingRestController::class );
}
}
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/amex.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/amex.svg
new file mode 100644
index 00000000000..6efb571092f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/amex.svg
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/diners.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/diners.svg
new file mode 100644
index 00000000000..ddc73dfcd75
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/diners.svg
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/discover.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/discover.svg
new file mode 100644
index 00000000000..9fab15cb822
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/discover.svg
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/interac.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/interac.svg
new file mode 100644
index 00000000000..de54495079e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/interac.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/jcb.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/jcb.svg
new file mode 100644
index 00000000000..674fd14272f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/jcb.svg
@@ -0,0 +1,29 @@
+
+
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/mastercard.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/mastercard.svg
new file mode 100644
index 00000000000..680a4669a72
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/mastercard.svg
@@ -0,0 +1,31 @@
+
+
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/unknown.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/unknown.svg
new file mode 100644
index 00000000000..dbb99b2f039
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/unknown.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/visa.svg b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/visa.svg
new file mode 100644
index 00000000000..b40af5cfa9f
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/CardIcons/visa.svg
@@ -0,0 +1,17 @@
+
+
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php
new file mode 100644
index 00000000000..1c6ec83cf77
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingEngine.php
@@ -0,0 +1,350 @@
+transient_files_engine = $transient_files_engine;
+ $this->legacy_proxy = $legacy_proxy;
+ }
+
+ /**
+ * Get the (transient) file name of the receipt for an order, creating a new file if necessary.
+ *
+ * If $force_new is false, and a receipt file for the order already exists (as pointed by order meta key
+ * RECEIPT_FILE_NAME_META_KEY), then the name of the already existing receipt file is returned.
+ *
+ * If $force_new is true, OR if it's false but no receipt file for the order exists (no order meta with key
+ * RECEIPT_FILE_NAME_META_KEY exists, OR it exists but the file it points to doesn't), then a new receipt
+ * transient file is created with the supplied expiration date (defaulting to "tomorrow"), and the new file name
+ * is stored as order meta with the key RECEIPT_FILE_NAME_META_KEY.
+ *
+ * @param int|WC_Order $order The order object or order id to get the receipt for.
+ * @param string|int|null $expiration_date GMT expiration date formatted as yyyy-mm-dd, or as a timestamp, or null for "tomorrow".
+ * @param bool $force_new If true, creates a new receipt file even if one already exists for the order.
+ * @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.
+ * @throws \InvalidArgumentException Invalid expiration date (wrongly formatted, or it's a date in the past).
+ * @throws Exception The directory to store the file doesn't exist and can't be created.
+ */
+ public function generate_receipt( $order, $expiration_date = null, bool $force_new = false ) : ?string {
+ if ( ! $order instanceof WC_Order ) {
+ $order = wc_get_order( $order );
+ if ( false === $order ) {
+ return null;
+ }
+ }
+
+ if ( ! $force_new ) {
+ $existing_receipt_filename = $this->get_existing_receipt( $order );
+ if ( ! is_null( $existing_receipt_filename ) ) {
+ return $existing_receipt_filename;
+ }
+ }
+
+ $expiration_date ??=
+ $this->legacy_proxy->call_function(
+ 'gmdate',
+ 'Y-m-d',
+ $this->legacy_proxy->call_function(
+ 'strtotime',
+ '+1 days'
+ )
+ );
+
+ // phpcs:ignore WordPress.PHP.DontExtract.extract_extract
+ extract( $this->get_order_data( $order ) );
+
+ ob_start();
+ include __dir__ . '/Templates/order-receipt.php';
+ $rendered_template = ob_get_contents();
+ ob_end_clean();
+
+ $file_name = $this->transient_files_engine->create_transient_file( $rendered_template, $expiration_date );
+
+ $order->update_meta_data( self::RECEIPT_FILE_NAME_META_KEY, $file_name );
+ $order->save_meta_data();
+
+ return $file_name;
+ }
+
+ /**
+ * Get the file name of an existing receipt file for an order.
+ *
+ * A receipt is considered to be available for the order if there's an order meta entry with key
+ * RECEIPT_FILE_NAME_META_KEY AND the transient file it points to exists AND it has not expired.
+ *
+ * @param WC_Order $order The order object or order id to get the receipt for.
+ * @return string|null The receipt file name, or null if no receipt is currently available for the order.
+ * @throws Exception Thrown if a wrong file path is passed.
+ */
+ public function get_existing_receipt( $order ): ?string {
+ if ( ! $order instanceof WC_Order ) {
+ $order = wc_get_order( $order );
+ if ( false === $order ) {
+ return null;
+ }
+ }
+
+ $existing_receipt_filename = $order->get_meta( self::RECEIPT_FILE_NAME_META_KEY, true );
+
+ if ( '' === $existing_receipt_filename ) {
+ return null;
+ }
+
+ $file_path = $this->transient_files_engine->get_transient_file_path( $existing_receipt_filename );
+ if ( is_null( $file_path ) ) {
+ return null;
+ }
+
+ return $this->transient_files_engine->file_has_expired( $file_path ) ? null : $existing_receipt_filename;
+ }
+
+ /**
+ * Get the order data that the receipt template will use.
+ *
+ * @param WC_Order $order The order to get the data from.
+ * @return array The order data as an associative array.
+ */
+ private function get_order_data( WC_Order $order ): array {
+ $store_name = get_bloginfo( 'name' );
+ if ( $store_name ) {
+ /* translators: %s = store name */
+ $receipt_title = sprintf( __( 'Receipt from %s', 'woocommerce' ), $store_name );
+ } else {
+ $receipt_title = __( 'Receipt', 'woocommerce' );
+ }
+
+ $order_id = $order->get_id();
+ if ( $order_id ) {
+ /* translators: %d = order id */
+ $summary_title = sprintf( __( 'Summary: Order #%d', 'woocommerce' ), $order->get_id() );
+ } else {
+ $summary_title = __( 'Summary', 'woocommerce' );
+ }
+
+ $get_price_args = array( 'currency' => $order->get_currency() );
+
+ $line_items_info = array();
+ $line_items = $order->get_items( 'line_item' );
+ foreach ( $line_items as $line_item ) {
+ $line_item_product = $line_item->get_product();
+ $line_item_title =
+ ( $line_item_product instanceof \WC_Product_Variation ) ?
+ ( wc_get_product( $line_item_product->get_parent_id() )->get_name() ) . '. ' . $line_item_product->get_attribute_summary() :
+ $line_item_product->get_name();
+ $line_items_info[] = array(
+ 'title' => wp_kses( $line_item_title, array() ),
+ 'quantity' => $line_item->get_quantity(),
+ 'amount' => wc_price( $line_item->get_subtotal(), $get_price_args ),
+ );
+ }
+
+ $line_items_info[] = array(
+ 'title' => __( 'Subtotal', 'woocommerce' ),
+ 'amount' => wc_price( $order->get_subtotal(), $get_price_args ),
+ );
+
+ $coupon_names = ArrayUtil::select( $order->get_coupons(), 'get_name', ArrayUtil::SELECT_BY_OBJECT_METHOD );
+ if ( ! empty( $coupon_names ) ) {
+ $line_items_info[] = array(
+ /* translators: %s = comma-separated list of coupon codes */
+ 'title' => sprintf( __( 'Discount (%s)', 'woocommerce' ), join( ', ', $coupon_names ) ),
+ 'amount' => wc_price( -$order->get_total_discount(), $get_price_args ),
+ );
+ }
+
+ foreach ( $order->get_fees() as $fee ) {
+ $name = $fee->get_name();
+ $line_items_info[] = array(
+ 'title' => '' === $name ? __( 'Fee', 'woocommerce' ) : $name,
+ 'amount' => wc_price( $fee->get_total(), $get_price_args ),
+ );
+ }
+
+ $shipping_total = (float) $order->get_shipping_total();
+ if ( $shipping_total ) {
+ $line_items_info[] = array(
+ 'title' => __( 'Shipping', 'woocommerce' ),
+ 'amount' => wc_price( $order->get_shipping_total(), $get_price_args ),
+ );
+ }
+
+ $total_taxes = 0;
+ foreach ( $order->get_taxes() as $tax ) {
+ $total_taxes += (float) $tax->get_tax_total() + (float) $tax->get_shipping_tax_total();
+ }
+
+ if ( $total_taxes ) {
+ $line_items_info[] = array(
+ 'title' => __( 'Taxes', 'woocommerce' ),
+ 'amount' => wc_price( $total_taxes, $get_price_args ),
+ );
+ }
+
+ $line_items_info[] = array(
+ 'title' => __( 'Amount Paid', 'woocommerce' ),
+ 'amount' => wc_price( $order->get_total(), $get_price_args ),
+ );
+
+ return array(
+ 'constants' => array(
+ 'font_size' => self::FONT_SIZE,
+ 'margin' => self::MARGIN,
+ 'title_font_size' => self::TITLE_FONT_SIZE,
+ 'footer_font_size' => self::FOOTER_FONT_SIZE,
+ 'line_height' => self::LINE_HEIGHT,
+ 'icon_height' => self::ICON_HEIGHT,
+ 'icon_width' => self::ICON_WIDTH,
+ ),
+ 'texts' => array(
+ 'receipt_title' => $receipt_title,
+ 'amount_paid_section_title' => __( 'Amount Paid', 'woocommerce' ),
+ 'date_paid_section_title' => __( 'Date Paid', 'woocommerce' ),
+ 'payment_method_section_title' => __( 'Payment method', 'woocommerce' ),
+ 'summary_section_title' => $summary_title,
+ 'order_notes_section_title' => __( 'Notes', 'woocommerce' ),
+ 'app_name' => __( 'Application Name', 'woocommerce' ),
+ 'aid' => __( 'AID', 'woocommerce' ),
+ 'account_type' => __( 'Account Type', 'woocommerce' ),
+ ),
+ 'formatted_amount' => wc_price( $order->get_total(), $get_price_args ),
+ 'formatted_date' => wc_format_datetime( $order->get_date_paid() ),
+ 'line_items' => $line_items_info,
+ 'payment_method' => $order->get_payment_method_title(),
+ 'notes' => array_map( 'get_comment_text', $order->get_customer_order_notes() ),
+ 'payment_info' => $this->get_woo_pay_data( $order ),
+ );
+ }
+
+ /**
+ * Get the order data related to WooCommerce Payments.
+ *
+ * It will return null if any of these is true:
+ *
+ * - Payment method is not 'woocommerce_payments".
+ * - WooCommerce Payments is not installed.
+ * - No intent id is stored for the order.
+ * - Retrieving the payment information from Stripe API (providing the intent id) fails.
+ * - The received data set doesn't contain the expected information.
+ *
+ * @param WC_Order $order The order to get the data from.
+ * @return array|null An array of payment information for the order, or null if not available.
+ */
+ private function get_woo_pay_data( WC_Order $order ): ?array {
+ // For testing purposes: if WooCommerce Payments development mode is enabled,
+ // an order meta item with key '_wcpay_payment_details' will be used if it exists as a replacement
+ // for the call to the Stripe API's 'get intent' endpoint.
+ // The value must be the JSON encoding of an array simulating the "payment_details" part of the response from the endpoint
+ // (at the very least it must contain the "card_present" key).
+ $payment_details = json_decode( defined( 'WCPAY_DEV_MODE' ) && WCPAY_DEV_MODE ? $order->get_meta( '_wcpay_payment_details' ) : false, true );
+
+ if ( ! $payment_details ) {
+ if ( 'woocommerce_payments' !== $order->get_payment_method() ) {
+ return null;
+ }
+
+ if ( ! class_exists( \WC_Payments::class ) ) {
+ return null;
+ }
+
+ $intent_id = $order->get_meta( '_intent_id' );
+ if ( ! $intent_id ) {
+ return null;
+ }
+
+ try {
+ $payment_details = \WC_Payments::get_payments_api_client()->get_intent( $intent_id )->get_charge()->get_payment_method_details();
+ } catch ( Exception $ex ) {
+ $order_id = $order->get_id();
+ $message = $ex->getMessage();
+ wc_get_logger()->error( StringUtil::class_name_without_namespace( static::class ) . " - retrieving info for charge {$intent_id} for order {$order_id}: {$message}" );
+ return null;
+ }
+ }
+
+ $card_data = $payment_details['card_present'] ?? null;
+ if ( is_null( $card_data ) ) {
+ return null;
+ }
+
+ $card_brand = $card_data['brand'] ?? '';
+ if ( ! in_array( $card_brand, self::KNOWN_CARD_TYPES, true ) ) {
+ $card_brand = 'unknown';
+ }
+
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $card_svg = base64_encode( file_get_contents( __DIR__ . "/CardIcons/{$card_brand}.svg" ) );
+
+ return array(
+ 'card_icon' => $card_svg,
+ 'card_last4' => wp_kses( $card_data['last4'] ?? '', array() ),
+ 'app_name' => wp_kses( $card_data['receipt']['application_preferred_name'] ?? null, array() ),
+ 'aid' => wp_kses( $card_data['receipt']['dedicated_file_name'] ?? null, array() ),
+ 'account_type' => wp_kses( $card_data['receipt']['account_type'] ?? null, array() ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingRestController.php b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingRestController.php
new file mode 100644
index 00000000000..3674da31e7b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/ReceiptRenderingRestController.php
@@ -0,0 +1,203 @@
+route_namespace,
+ '/orders/(?P[\d]+)/receipt',
+ array(
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => fn( $request ) => $this->run( $request, 'create_order_receipt' ),
+ 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_shop_order', $request->get_param( 'id' ) ),
+ 'args' => $this->get_args_for_create_order_receipt(),
+ 'schema' => $this->get_schema_for_get_and_post_order_receipt(),
+ ),
+ )
+ );
+
+ register_rest_route(
+ $this->route_namespace,
+ '/orders/(?P[\d]+)/receipt',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => fn( $request ) => $this->run( $request, 'get_order_receipt' ),
+ 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_shop_order', $request->get_param( 'id' ) ),
+ 'args' => $this->get_args_for_get_order_receipt(),
+ 'schema' => $this->get_schema_for_get_and_post_order_receipt(),
+ ),
+ )
+ );
+
+ }
+
+ /**
+ * Handle the GET /orders/id/receipt:
+ *
+ * Return the data for a receipt if it exists, or a 404 error if it doesn't.
+ *
+ * @param WP_REST_Request $request The received request.
+ * @return array|WP_Error
+ */
+ public function get_order_receipt( WP_REST_Request $request ) {
+ $order_id = $request->get_param( 'id' );
+ $filename = wc_get_container()->get( ReceiptRenderingEngine::class )->get_existing_receipt( $order_id );
+
+ return is_null( $filename ) ?
+ new WP_Error( 'woocommerce_rest_not_found', __( 'Receipt not found', 'woocommerce' ), array( 'status' => 404 ) ) :
+ $this->get_response_for_file( $filename );
+ }
+
+ /**
+ * Handle the POST /orders/id/receipt:
+ *
+ * Return the data for a receipt if it exists, or create a new receipt and return its data otherwise.
+ *
+ * Optional query string arguments:
+ *
+ * expiration_date: formatted as yyyy-mm-dd.
+ * expiration_days: a number, 0 is today, 1 is tomorrow, etc.
+ * force_new: defaults to false, if true, create a new receipt even if one already exists for the order.
+ *
+ * If neither expiration_date nor expiration_days are supplied, the default is expiration_days = 1.
+ *
+ * @param WP_REST_Request $request The received request.
+ * @return array|WP_Error Request response or an error.
+ */
+ public function create_order_receipt( WP_REST_Request $request ) {
+ $expiration_date =
+ $request->get_param( 'expiration_date' ) ??
+ gmdate( 'Y-m-d', strtotime( "+{$request->get_param('expiration_days')} days" ) );
+
+ $order_id = $request->get_param( 'id' );
+
+ $filename = wc_get_container()->get( ReceiptRenderingEngine::class )->generate_receipt( $order_id, $expiration_date, $request->get_param( 'force_new' ) );
+
+ return is_null( $filename ) ?
+ new WP_Error( 'woocommerce_rest_not_found', __( 'Order not found', 'woocommerce' ), array( 'status' => 404 ) ) :
+ $this->get_response_for_file( $filename );
+ }
+
+ /**
+ * Formats the response for both the GET and POST endpoints.
+ *
+ * @param string $filename The filename to return the information for.
+ * @return array The data for the actual response to be returned.
+ */
+ private function get_response_for_file( string $filename ): array {
+ $expiration_date = TransientFilesEngine::get_expiration_date( $filename );
+ $public_url = wc_get_container()->get( TransientFilesEngine::class )->get_public_url( $filename );
+
+ return array(
+ 'receipt_url' => $public_url,
+ 'expiration_date' => $expiration_date,
+ );
+ }
+
+ /**
+ * Get the accepted arguments for the GET request.
+ *
+ * @return array[] The accepted arguments for the GET request.
+ */
+ private function get_args_for_get_order_receipt(): array {
+ return array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier of the order.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ );
+ }
+
+ /**
+ * Get the schema for both the GET and the POST requests.
+ *
+ * @return array[]
+ */
+ private function get_schema_for_get_and_post_order_receipt(): array {
+ $schema = $this->get_base_schema();
+ $schema['properties'] = array(
+ 'receipt_url' => array(
+ 'description' => __( 'Public url of the receipt.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'expiration_date' => array(
+ 'description' => __( 'Expiration date of the receipt, formatted as yyyy-mm-dd.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ );
+
+ return $schema;
+ }
+
+ /**
+ * Get the accepted arguments for the POST request.
+ *
+ * @return array[]
+ */
+ private function get_args_for_create_order_receipt(): array {
+ return array(
+ 'id' => array(
+ 'description' => __( 'Unique identifier of the order.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ ),
+ 'expiration_date' => array(
+ 'description' => __( 'Expiration date formatted as yyyy-mm-dd.', 'woocommerce' ),
+ 'type' => 'string',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ 'default' => null,
+ ),
+ 'expiration_days' => array(
+ 'description' => __( 'Number of days to be added to the current date to get the expiration date.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ 'default' => 1,
+ ),
+ 'force_new' => array(
+ 'description' => __( 'True to force the creation of a new receipt even if one already exists and has not expired yet.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'required' => false,
+ 'context' => array( 'view', 'edit' ),
+ 'readonly' => true,
+ 'default' => false,
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/ReceiptRendering/Templates/order-receipt.php b/plugins/woocommerce/src/Internal/ReceiptRendering/Templates/order-receipt.php
new file mode 100644
index 00000000000..176013bad5a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ReceiptRendering/Templates/order-receipt.php
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/plugins/woocommerce/src/Internal/RestApiControllerBase.php b/plugins/woocommerce/src/Internal/RestApiControllerBase.php
new file mode 100644
index 00000000000..5a08f8ec4d8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApiControllerBase.php
@@ -0,0 +1,233 @@
+route_namespace,
+ * '/foobars/(?P[\d]+)',
+ * array(
+ * array(
+ * 'methods' => \WP_REST_Server::READABLE,
+ * 'callback' => fn( $request ) => $this->run( $request, 'get_foobar' ),
+ * 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'read_foobars', $request->get_param( 'id' ) ),
+ * 'args' => $this->get_args_for_get_foobar(),
+ * 'schema' => $this->get_schema_for_get_foobar(),
+ * ),
+ * )
+ * );
+ * }
+ *
+ * protected function get_foobar( \WP_REST_Request $request ) {
+ * return array( 'message' => 'Get foobar with id ' . $request->get_param(' id' ) );
+ * }
+ *
+ * private function get_args_for_get_foobar(): array {
+ * return array(
+ * 'id' => array(
+ * 'description' => __( 'Unique identifier of the foobar.', 'woocommerce' ),
+ * 'type' => 'integer',
+ * 'context' => array( 'view', 'edit' ),
+ * 'readonly' => true,
+ * ),
+ * );
+ * }
+ *
+ * private function get_schema_for_get_foobar(): array {
+ * $schema = $this->get_base_schema();
+ * $schema['properties'] = array(
+ * 'message' => array(
+ * 'description' => __( 'A message.', 'woocommerce' ),
+ * 'type' => 'string',
+ * 'context' => array( 'view', 'edit' ),
+ * 'readonly' => true,
+ * ),
+ * );
+ * return $schema;
+ * }
+ *
+ * }
+ */
+abstract class RestApiControllerBase implements RegisterHooksInterface {
+ use AccessiblePrivateMethods;
+
+ /**
+ * The root namespace for the JSON REST API endpoints.
+ *
+ * @var string
+ */
+ protected string $route_namespace = 'wc/v3';
+
+ /**
+ * Holds authentication error messages for each HTTP verb.
+ *
+ * @var array
+ */
+ protected array $authentication_errors_by_method;
+
+ /**
+ * Class constructor.
+ */
+ public function __construct() {
+ $this->authentication_errors_by_method = array(
+ 'GET' => array(
+ 'code' => 'woocommerce_rest_cannot_view',
+ 'message' => __( 'Sorry, you cannot view resources.', 'woocommerce' ),
+ ),
+ 'POST' => array(
+ 'code' => 'woocommerce_rest_cannot_create',
+ 'message' => __( 'Sorry, you cannot create resources.', 'woocommerce' ),
+ ),
+ 'DELETE' => array(
+ 'code' => 'woocommerce_rest_cannot_delete',
+ 'message' => __( 'Sorry, you cannot delete resources.', 'woocommerce' ),
+ ),
+ );
+ }
+
+ /**
+ * Register the hooks used by the class.
+ */
+ public function register() {
+ static::add_filter( 'woocommerce_rest_api_get_rest_namespaces', array( $this, 'handle_woocommerce_rest_api_get_rest_namespaces' ) );
+ }
+
+ /**
+ * Handle the woocommerce_rest_api_get_rest_namespaces filter
+ * to add ourselves to the list of REST API controllers registered by WooCommerce.
+ *
+ * @param array $namespaces The original list of WooCommerce REST API namespaces/controllers.
+ * @return array The updated list of WooCommerce REST API namespaces/controllers.
+ */
+ protected function handle_woocommerce_rest_api_get_rest_namespaces( array $namespaces ): array {
+ $namespaces['wc/v3'][ $this->get_rest_api_namespace() ] = static::class;
+ return $namespaces;
+ }
+
+ /**
+ * Get the WooCommerce REST API namespace for the class. It must be unique across all other derived classes
+ * and the keys returned by the 'get_vX_controllers' methods in includes/rest-api/Server.php.
+ * Note that this value is NOT related to the route namespace.
+ *
+ * @return string
+ */
+ abstract protected function get_rest_api_namespace(): string;
+
+ /**
+ * Register the REST API endpoints handled by this controller.
+ *
+ * Use 'register_rest_route' in the usual way, it's recommended to use the 'run' method for 'callback'
+ * and the 'check_permission' method for 'permission_check', see the example in the class comment.
+ */
+ abstract public function register_routes();
+
+ /**
+ * Handle a request for one of the provided REST API endpoints.
+ *
+ * If an exception is thrown, the exception message will be returned as part of the response
+ * if the user has the 'manage_woocommerce' capability.
+ *
+ * Note that the method specified in $method_name must have a 'protected' visibility and accept one argument of type 'WP_REST_Request'.
+ *
+ * @param WP_REST_Request $request The incoming HTTP REST request.
+ * @param string $method_name The name of the class method to execute. It must be protected and accept one argument of type 'WP_REST_Request'.
+ * @return WP_Error|WP_HTTP_Response|WP_REST_Response The response to send back to the client.
+ */
+ protected function run( WP_REST_Request $request, string $method_name ) {
+ try {
+ return rest_ensure_response( $this->$method_name( $request ) );
+ } catch ( InvalidArgumentException $ex ) {
+ $message = $ex->getMessage();
+ return new WP_Error( 'woocommerce_rest_invalid_argument', $message ? $message : __( 'Internal server error', 'woocommerce' ), array( 'status' => 400 ) );
+ } catch ( Exception $ex ) {
+ wc_get_logger()->error( StringUtil::class_name_without_namespace( static::class ) . ": when executing method $method_name: {$ex->getMessage()}" );
+ return $this->internal_wp_error( $ex );
+ }
+ }
+
+ /**
+ * Return an WP_Error object for an internal server error, with exception information if the current user is an admin.
+ *
+ * @param Exception $exception The exception to maybe include information from.
+ * @return WP_Error
+ */
+ protected function internal_wp_error( Exception $exception ): WP_Error {
+ $data = array( 'status' => 500 );
+ if ( current_user_can( 'manage_woocommerce' ) ) {
+ $data['exception_class'] = get_class( $exception );
+ $data['exception_message'] = $exception->getMessage();
+ $data['exception_trace'] = (array) $exception->getTrace();
+ }
+ $data['exception_message'] = $exception->getMessage();
+
+ return new WP_Error( 'woocommerce_rest_internal_error', __( 'Internal server error', 'woocommerce' ), $data );
+ }
+
+ /**
+ * Permission check for REST API endpoints, given the request method.
+ *
+ * @param WP_REST_Request $request The request for which the permission is checked.
+ * @param string $required_capability_name The name of the required capability.
+ * @param mixed ...$extra_args Extra arguments to be used for the permission check.
+ * @return bool|WP_Error True if the current user has the capability, otherwise an "Unauthorized" error or False if no error is available for the request method.
+ */
+ protected function check_permission( WP_REST_Request $request, string $required_capability_name, ...$extra_args ) {
+ if ( current_user_can( $required_capability_name, $extra_args ) ) {
+ return true;
+ }
+
+ $error_information = $this->authentication_errors_by_method[ $request->get_method() ] ?? null;
+ if ( is_null( $error_information ) ) {
+ return false;
+ }
+
+ return new WP_Error(
+ $error_information['code'],
+ $error_information['message'],
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ /**
+ * Get the base schema for the REST API endpoints.
+ *
+ * @return array
+ */
+ protected function get_base_schema(): array {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'order receipts',
+ 'type' => 'object',
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php b/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php
index 0f46b8d1614..27157ed4471 100644
--- a/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php
+++ b/plugins/woocommerce/src/Internal/TransientFiles/TransientFilesEngine.php
@@ -54,7 +54,7 @@ class TransientFilesEngine implements RegisterHooksInterface {
self::add_action( self::CLEANUP_ACTION_NAME, array( $this, 'handle_expired_files_cleanup_action' ) );
self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_debug_tools_entries' ), 999, 1 );
- self::add_action( 'init', array( $this, 'handle_init' ), 0 );
+ self::add_action( 'init', array( $this, 'add_endpoint' ), 0 );
self::add_filter( 'query_vars', array( $this, 'handle_query_vars' ), 0 );
self::add_action( 'parse_request', array( $this, 'handle_parse_request' ), 0 );
}
@@ -109,6 +109,14 @@ class TransientFilesEngine implements RegisterHooksInterface {
if ( ! $this->legacy_proxy->call_function( 'wp_mkdir_p', $transient_files_directory ) ) {
throw new Exception( "Can't create directory: $transient_files_directory" );
}
+
+ // Create infrastructure to prevent listing the contents of the transient files directory.
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ \WP_Filesystem();
+ $wp_filesystem = $this->legacy_proxy->get_global( 'wp_filesystem' );
+ $wp_filesystem->put_contents( $transient_files_directory . '/.htaccess', 'deny from all' );
+ $wp_filesystem->put_contents( $transient_files_directory . '/index.html', '' );
+
$realpathed_transient_files_directory = $this->legacy_proxy->call_function( 'realpath', $transient_files_directory );
} else {
throw new Exception( "The base transient files directory doesn't exist: $transient_files_directory" );
@@ -153,7 +161,8 @@ class TransientFilesEngine implements RegisterHooksInterface {
}
$filepath = $transient_files_directory . '/' . $filename;
- WP_Filesystem();
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ \WP_Filesystem();
$wp_filesystem = $this->legacy_proxy->get_global( 'wp_filesystem' );
if ( false === $wp_filesystem->put_contents( $filepath, $file_contents ) ) {
throw new Exception( "Can't create file: $filepath" );
@@ -175,6 +184,23 @@ class TransientFilesEngine implements RegisterHooksInterface {
* @return string|null The full physical path of the file, or null if the files doesn't exist.
*/
public function get_transient_file_path( string $filename ): ?string {
+ $expiration_date = self::get_expiration_date( $filename );
+ if ( is_null( $expiration_date ) ) {
+ return null;
+ }
+
+ $file_path = $this->get_transient_files_directory() . '/' . $expiration_date . '/' . substr( $filename, 6 );
+
+ return is_file( $file_path ) ? $file_path : null;
+ }
+
+ /**
+ * Get the expiration date of a transient file based on its file name. The actual existence of the file is NOT checked.
+ *
+ * @param string $filename The name of the transient file to get the expiration date for.
+ * @return string|null Expiration date formatted as Y-m-d, null if the file name isn't encoding a proper date.
+ */
+ public static function get_expiration_date( string $filename ) : ?string {
if ( strlen( $filename ) < 7 || ! ctype_xdigit( $filename ) ) {
return null;
}
@@ -185,13 +211,18 @@ class TransientFilesEngine implements RegisterHooksInterface {
hexdec( substr( $filename, 3, 1 ) ),
hexdec( substr( $filename, 4, 2 ) )
);
- if ( ! TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ) {
- return null;
- }
- $file_path = $this->get_transient_files_directory() . '/' . $expiration_date . '/' . substr( $filename, 6 );
+ return TimeUtil::is_valid_date( $expiration_date, 'Y-m-d' ) ? $expiration_date : null;
+ }
- return is_file( $file_path ) ? $file_path : null;
+ /**
+ * Get the public URL of a transient file. The file name is NOT checked for validity or actual existence.
+ *
+ * @param string $filename The name of the transient file to get the public URL for.
+ * @return string The public URL of the file.
+ */
+ public function get_public_url( string $filename ) {
+ return $this->legacy_proxy->call_function( 'get_site_url', null, '/wc/file/transient/' . $filename );
}
/**
@@ -394,7 +425,7 @@ class TransientFilesEngine implements RegisterHooksInterface {
/**
* Handle the "init" action, add rewrite rules for the "wc/file" endpoint.
*/
- private function handle_init() {
+ public static function add_endpoint() {
add_rewrite_rule( '^wc/file/transient/?$', 'index.php?wc-transient-file-name=', 'top' );
add_rewrite_rule( '^wc/file/transient/(.+)$', 'index.php?wc-transient-file-name=$matches[1]', 'top' );
add_rewrite_endpoint( 'wc/file/transient', EP_ALL );
diff --git a/plugins/woocommerce/tests/php/src/Internal/ReceiptRendering/ReceiptRenderingEngineTest.php b/plugins/woocommerce/tests/php/src/Internal/ReceiptRendering/ReceiptRenderingEngineTest.php
new file mode 100644
index 00000000000..dcfce320b8d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ReceiptRendering/ReceiptRenderingEngineTest.php
@@ -0,0 +1,255 @@
+tfe_mock = $this->getMockBuilder( TransientFilesEngine::class )->getMock();
+ $this->sut = new ReceiptRenderingEngine();
+ $this->sut->init( $this->tfe_mock, wc_get_container()->get( LegacyProxy::class ) );
+ }
+
+ /**
+ * @testdox 'generate_receipt' returns null if the id of a not existing order is passed.
+ */
+ public function test_generate_receipt_for_no_existing_order_returns_null() {
+ $filename = $this->sut->generate_receipt( -1 );
+ $this->assertNull( $filename );
+ }
+
+ /**
+ * @testdox 'generate_receipt' returns the file name of an already existing receipt if 'force_new' is not true.
+ */
+ public function test_generate_receipt_returns_existing_receipt_if_force_new_is_not_true() {
+ $order = OrderHelper::create_order();
+
+ $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' );
+
+ $this->tfe_mock
+ ->expects( self::never() )
+ ->method( 'create_transient_file' );
+
+ $this->tfe_mock
+ ->expects( self::once() )
+ ->method( 'get_transient_file_path' )
+ ->with( 'the_existing_receipt_filename' )
+ ->willReturn( 'transient_files/the_existing_receipt_filename' );
+
+ $filename = $this->sut->generate_receipt( $order );
+ $this->assertEquals( 'the_existing_receipt_filename', $filename );
+
+ $meta = $order->get_meta( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY );
+ $this->assertEquals( 'the_existing_receipt_filename', $meta );
+ }
+
+ /**
+ * @testdox 'generate_receipt' returns the file name of a new receipt if 'force_new' is not true but no receipt file exists.
+ */
+ public function test_generate_receipt_returns_new_receipt_file_if_no_receipt_exists_and_force_new_is_not_true() {
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'strtotime' => fn ( $arg) => '+1 days' === $arg ? -1 : strtotime( $arg ),
+ 'gmdate' => fn ( $format, $value) => 'Y-m-d' === $format && -1 === $value ? '2999-12-31' : gmdate( $format, $value ),
+ )
+ );
+
+ $order = OrderHelper::create_order();
+
+ $this->tfe_mock
+ ->expects( self::once() )
+ ->method( 'create_transient_file' )
+ ->with( self::isType( 'string' ), self::equalTo( '2999-12-31' ) )
+ ->willReturn( 'the_generated_file_name' );
+
+ $filename = $this->sut->generate_receipt( $order );
+ $this->assertEquals( 'the_generated_file_name', $filename );
+
+ $meta = $order->get_meta( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY );
+ $this->assertEquals( 'the_generated_file_name', $meta );
+ }
+
+ /**
+ * @testdox 'generate_receipt' returns the file name of a new receipt if a receipt file already exists but 'force_new' is true.
+ */
+ public function test_generate_receipt_returns_new_receipt_file_if_receipt_exists_but_force_new_is_true() {
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'strtotime' => fn ( $arg) => '+1 days' === $arg ? -1 : strtotime( $arg ),
+ 'gmdate' => fn ( $format, $value) => 'Y-m-d' === $format && -1 === $value ? '2999-12-31' : gmdate( $format, $value ),
+ )
+ );
+
+ $order = OrderHelper::create_order();
+
+ $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' );
+
+ $this->tfe_mock
+ ->expects( self::never() )
+ ->method( 'get_transient_file_path' );
+
+ $this->tfe_mock
+ ->expects( self::once() )
+ ->method( 'create_transient_file' )
+ ->with( self::isType( 'string' ), self::equalTo( '2999-12-31' ) )
+ ->willReturn( 'the_generated_file_name' );
+
+ $filename = $this->sut->generate_receipt( $order, null, true );
+ $this->assertEquals( 'the_generated_file_name', $filename );
+
+ $meta = $order->get_meta( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY );
+ $this->assertEquals( 'the_generated_file_name', $meta );
+ }
+
+ /**
+ * @testdox 'generate_receipt' uses the supplied expiration date to create the transient file.
+ */
+ public function test_generate_receipt_with_custom_expiration_date() {
+ $order = OrderHelper::create_order();
+
+ $this->tfe_mock
+ ->expects( self::once() )
+ ->method( 'create_transient_file' )
+ ->with( self::isType( 'string' ), self::equalTo( '2888-10-20' ) )
+ ->willReturn( 'the_generated_file_name' );
+
+ $filename = $this->sut->generate_receipt( $order, '2888-10-20' );
+ $this->assertEquals( 'the_generated_file_name', $filename );
+
+ $meta = $order->get_meta( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY );
+ $this->assertEquals( 'the_generated_file_name', $meta );
+ }
+
+ /**
+ * @testdox 'generate_receipt' throws an exception if an invalid expiration date is supplied.
+ *
+ * @testWith ["NOT_A_DATE"]
+ * ["2000-01-01"]
+ * ["2999-34-89"]
+ *
+ * @param string $expiration_date The expiration date to test.
+ */
+ public function test_generate_receipt_throws_for_bad_expiration_date( string $expiration_date ) {
+ $this->expectException( \InvalidArgumentException::class );
+
+ $sut = wc_get_container()->get( ReceiptRenderingEngine::class );
+
+ $order = OrderHelper::create_order();
+ $sut->generate_receipt( $order, $expiration_date );
+ }
+
+ /**
+ * @testdox 'get_existing_receipt' returns null if the id of a not existing order is passed.
+ */
+ public function test_get_existing_receipt_for_no_existing_order_returns_null() {
+ $filename = $this->sut->get_existing_receipt( -1 );
+ $this->assertNull( $filename );
+ }
+
+ /**
+ * @testdox 'get_existing_receipt' returns the file name of an existing receipt if the appropriate order meta entry exists and the file actually exists too and has not expired.
+ */
+ public function test_get_existing_receipt_returns_existing_receipt() {
+ $order = OrderHelper::create_order();
+
+ $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' );
+
+ $this->tfe_mock
+ ->expects( self::once() )
+ ->method( 'get_transient_file_path' )
+ ->with( 'the_existing_receipt_filename' )
+ ->willReturn( 'transient_files/the_existing_receipt_filename' );
+
+ $this->tfe_mock
+ ->expects( self::once() )
+ ->method( 'file_has_expired' )
+ ->with( 'transient_files/the_existing_receipt_filename' )
+ ->willReturn( false );
+
+ $filename = $this->sut->get_existing_receipt( $order );
+ $this->assertEquals( 'the_existing_receipt_filename', $filename );
+ }
+
+ /**
+ * @testdox 'get_existing_receipt' returns the file name of an existing receipt if the appropriate order meta entry doesn't exist.
+ */
+ public function test_get_existing_receipt_returns_null_if_no_meta_entry() {
+ $order = OrderHelper::create_order();
+
+ $this->tfe_mock
+ ->expects( self::never() )
+ ->method( 'get_transient_file_path' );
+
+ $filename = $this->sut->get_existing_receipt( $order );
+ $this->assertNull( $filename );
+ }
+
+ /**
+ * @testdox 'get_existing_receipt' returns the file name of an existing receipt if the appropriate order meta entry exists but the file doesn't.
+ */
+ public function test_get_existing_receipt_returns_null_if_meta_entry_exists_but_file_doesnt() {
+ $order = OrderHelper::create_order();
+
+ $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' );
+
+ $this->tfe_mock
+ ->expects( self::once() )
+ ->method( 'get_transient_file_path' )
+ ->with( 'the_existing_receipt_filename' )
+ ->willReturn( null );
+
+ $filename = $this->sut->get_existing_receipt( $order );
+ $this->assertNull( $filename );
+ }
+
+ /**
+ * @testdox 'get_existing_receipt' returns the file name of an existing receipt if the appropriate order meta entry exists and the file exists but has expired.
+ */
+ public function test_get_existing_receipt_returns_null_if_meta_entry_exists_but_file_has_expired() {
+ $order = OrderHelper::create_order();
+
+ $order->update_meta_data( ReceiptRenderingEngine::RECEIPT_FILE_NAME_META_KEY, 'the_existing_receipt_filename' );
+
+ $this->tfe_mock
+ ->expects( self::once() )
+ ->method( 'get_transient_file_path' )
+ ->with( 'the_existing_receipt_filename' )
+ ->willReturn( 'transient_files/the_existing_receipt_filename' );
+
+ $this->tfe_mock
+ ->expects( self::once() )
+ ->method( 'file_has_expired' )
+ ->with( 'transient_files/the_existing_receipt_filename' )
+ ->willReturn( true );
+
+ $filename = $this->sut->get_existing_receipt( $order );
+ $this->assertNull( $filename );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php b/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php
index c614f5a6a90..d774074fdec 100644
--- a/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/TransientFiles/TransientFilesEngineTest.php
@@ -258,8 +258,31 @@ class TransientFilesEngineTest extends \WC_REST_Unit_Test_Case {
)
);
+ // phpcs:disable Squiz.Commenting
+ $fake_wp_filesystem = new class() {
+ public $created_files = array();
+
+ public function put_contents( string $file, string $contents, $mode = false ): bool {
+ $this->created_files[ $file ] = $contents;
+ return strlen( $contents );
+ }
+ };
+ // phpcs:enable Squiz.Commenting
+
+ $this->register_legacy_proxy_global_mocks(
+ array(
+ 'wp_filesystem' => $fake_wp_filesystem,
+ )
+ );
+
$this->sut->get_transient_files_directory();
$this->assertEquals( '/wordpress/uploads/woocommerce_transient_files', $created_dir );
+
+ $expected_created_files = array(
+ '/wordpress/uploads/woocommerce_transient_files/.htaccess' => 'deny from all',
+ '/wordpress/uploads/woocommerce_transient_files/index.html' => '',
+ );
+ $this->assertEquals( $expected_created_files, $fake_wp_filesystem->created_files );
}
/**
@@ -301,6 +324,42 @@ class TransientFilesEngineTest extends \WC_REST_Unit_Test_Case {
$this->assertEquals( static::$transient_files_dir . '/2023-12-02/000102030405060708090a0b0c0d0e0f', $result );
}
+ /**
+ * @testdox get_expiration_date returns null for file names without a properly encoded expiration date.
+ *
+ * @testWith [""]
+ * ["123"]
+ * ["NOT_HEX_DATE112233"]
+ * ["7e8f01"]
+ *
+ * @param string $filename Filename to test.
+ */
+ public function test_get_expiration_date_returns_null_for_wrongly_formatted_date( string $filename ) {
+ $this->assertNull( TransientFilesEngine::get_expiration_date( $filename ) );
+ }
+
+ /**
+ * @testdox get_expiration_date returns the date encoded in a proper transient file name.
+ */
+ public function test_get_expiration_date_correctly_extracts_date_from_filename() {
+ $actual = TransientFilesEngine::get_expiration_date( '7e821b00000' );
+ $this->assertEquals( '2024-02-27', $actual );
+ }
+
+ /**
+ * @testdox get_public_url returns the full public URL of a transient file given its name.
+ */
+ public function test_get_public_url() {
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'get_site_url' => fn( $blog_id, $path) => 'http://example.com' . $path,
+ )
+ );
+
+ $actual = $this->sut->get_public_url( '1234abcd' );
+ $this->assertEquals( 'http://example.com/wc/file/transient/1234abcd', $actual );
+ }
+
/**
* @testdox file_has_expired return false for a file that hasn't expired.
*