Commit 3d0d1d323ef for woocommerce
commit 3d0d1d323ef04e09c635a8b53e0ce4b7bc9bb937
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Tue Nov 3 16:37:39 2026 -0300
Give every WC_Data method that saves inside itself a stage_*() twin (#70387)
* Stage payment_complete() and add_product() so order classes can adopt them
WC_Order::stage_payment_complete() makes the in-memory changes of
payment_complete(). The payment note and woocommerce_payment_complete
move to the next save(), which now runs a list of deferred callbacks
in place of the single pending edit status. payment_complete() is
stage_payment_complete() plus save().
WC_Abstract_Order::stage_product_item() builds the product line item
and adds it to the order without saving it. add_product() calls it,
saves the item, and returns the item ID as before.
Both saving methods warn through StagedMethod when a class overrides
them without the twin.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Stage the remaining order methods that save inside themselves
calculate_totals(), calculate_taxes(), update_taxes(),
calculate_shipping(), apply_coupon(), remove_coupon() and
recalculate_coupons() each get a stage_*() twin that changes the order
and its items in memory only. The original method is the twin plus
save().
The deferred list moves to WC_Abstract_Order, so refunds use it too.
Tax item saves, coupon usage counts and woocommerce_order_removed_coupon
run from save(), in the same place relative to the write as before.
set_coupon_discount_amounts() finds coupon items by code in memory, so
it no longer needs a save before recalculate_coupons() to give a new
coupon item an ID. WC_Order_Refund overrides stage_coupon() in place of
apply_coupon().
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Add a PHPStan rule that reports writes outside save()
The woocommerce.stagedWrite rule reports a non-static method of a
WC_Data subclass that saves, writes through the data store or calls a
write function, unless the method also calls a stage_*() method on
$this. The known exceptions and the methods not reviewed yet are listed
in phpstan.neon with a reason each.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Give every WC_Data write method a stage_*() twin or a reason
remove_order_items() gets stage_order_items_removal(). A custom store
that deletes items synchronously now gets that deletion from the
deferred list, and remove_order_items() runs it right away.
WC_Webhook gets stage_delivery_result(), which replaces the private
failed_delivery(), and stage_delivery_ping(). Notification gets
stage_verification_key() and stage_unsubscribe_key().
The PHPStan rule now exempts the save pipeline methods and deletes.
phpstan.neon excludes includes/legacy/ from the rule and keeps a reason
for each method where staging does not apply.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Keep the saves and hooks of the saving order methods for direct callers
Each saving method now runs its stage_*() twin through write_through().
While it does, the twin saves, writes and fires hooks at the same points
as trunk, so listeners of woocommerce_order_after_calculate_totals see
saved tax items with real IDs again. Called directly, the twin saves
nothing and defers those writes and hooks to save().
On the staged path, woocommerce_order_after_calculate_totals fires from
save(), right before the write, with a third argument set to true. The
direct path keeps its two arguments.
set_coupon_discount_amounts() matches by item ID as on trunk, and reuses
an unsaved coupon item by code only on the staged path.
WC_Order::stage_order_note() queues a note that save() adds with
add_order_note() after the write. add_order_note() is unchanged for
direct callers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* State when woocommerce_order_edit_status sees the paid and completed dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refuse a direct stage_*() call on a class that has not adopted the twin
A class that overrides a saving method without its stage_*() twin keeps
working through the saving method, which runs the twin through
write_through(). A direct call of the twin, which is what an ability
makes, refuses the class, so no ability skips the rules of the override.
The check lives in WC_Data, so it covers every WC_Data twin with one code,
and a PHPStan rule reports a twin that does not start with it. ActionableAbility
turns the refusal into a WP_Error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Give classes outside WC_Data a public check for the staging rule
WC_Data::refuse_unstaged_override() is protected and StagedMethod is internal,
so a service class that adds a stage method could follow the rule only by
convention. ExtensibleAbilities::refuse_unstaged_override() runs the same check
for any object, and WC_Data now calls it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep unsaved lines when discounts read an order
stage_recalculated_coupons() reads lines that have no IDs yet, and they all shared the key 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Pass the staged flag on every calculate_totals hook call and fold the note helper back
Fire woocommerce_order_after_calculate_totals with the same three arguments
on direct and staged calls. Put the note insert back in add_order_note().
Let the central catch in ActionableAbility handle a refused stage_status().
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Count only the saves of the order under test in the saving methods test
The HPOS data store saves a separate order instance when it records coupon
usage, and the posts data store writes post meta instead, so counting every
order save gave 4 with HPOS and 3 with posts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Record staged coupon usage once and save staged items with the order
Staged coupons now record their usage in one step after the save, against
the final coupon set. Two staged coupons, or a staged coupon followed by
payment_complete(), no longer count a coupon twice. A staged coupon that is
removed before the save releases no usage and leaves no identity behind.
stage_product_item() saves the item only for add_product(). Otherwise
save_items() writes it, so an item removed before the save is not written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Check stage methods for writes even when they call another stage method
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Document when the coupon hooks fire around a staged change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Count a coupon applied after a staged coupon once, and exempt only the twin's saving method
apply_coupon() now reads the recorded coupon usage before its save. The save can run
a staged coupon's deferred usage recording, which also counts the new coupon.
StagedWriteRule exempts a writing method only when it calls the stage method whose
refuse_unstaged_override() names it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Count a replacement coupon once after a staged removal
When an order stages the removal of its only recorded coupon, the save
clears the recorded-usage flag. The direct increment then counted the new
coupon, and wc_update_coupon_usage_counts() counted it again. Increment
directly only when the order still has recorded usage after the save.
The staged write rule also reads the saving method name from a
double-quoted refuse_unstaged_override() argument.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Read the refused saving method from tokens in the staged write rule
Whitespace or a comment between refuse_unstaged_override and its
parenthesis made the regex miss the call, and text in a comment could
match instead. The tokenizer skips whitespace and comments and reads the
first string argument of the real call.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Skip the after-save deferred actions when the order write fails
A staged change left actions for after the write, such as the staged
order note, the staged coupon usage and woocommerce_payment_complete.
They ran even when the write threw, so they reported a change that the
database did not have. save() now skips them when it caught an error.
Direct calls of the saving methods run their actions right away, so
they behave as before.
Add a changelog entry for the coupon usage that apply_coupon() no
longer counts twice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refuse stage_payment_complete() on an order that is not saved yet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep after-save callbacks queued when the order write fails
A failed save no longer drops the after-save callbacks it took from the
queue. They go back on the order and run once on the next successful
save, so a staged coupon, note or payment is not lost.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Fire the status change hooks only after a successful order write
When the write fails, the pending status transition stays on the order,
and the next successful save fires its hooks once. Before, the hooks
fired for a status that was not saved, and the later save did not fire
them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add a changelog entry for the status hooks after a failed order write
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename and rewrite the changelog entry for the stage methods
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test a totals listener that calls a saving method during a staged save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Catch writes through get_data_store() in the staged write rule
The rule now also reports a write through $this->get_data_store(). It
exempts hold_applied_coupons(), which takes a checkout lock and has to
write at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Move the staging refusal into StagedMethod
WC_Data now calls StagedMethod directly, so the base data class no longer
depends on the abilities API. ExtensibleAbilities::refuse_unstaged_override()
stays as a thin wrapper for classes outside WC_Data.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refuse the whole twin chain up front and stop staged entries running twice
A stage method that calls other stage methods now checks every pair of
its chain before it fires a hook or changes the order. Before, an order
class that overrides only calculate_totals() got
woocommerce_order_applied_coupon and the coupon, and then a refusal.
A saving method that runs after its stage method now drops the staged
entry, so the next save does not fire the hook a second time. A clone
starts without staged callbacks and coupons, so saving the clone does
not run them for the original.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/add-wc-data-stage-methods b/plugins/woocommerce/changelog/add-wc-data-stage-methods
new file mode 100644
index 00000000000..47006904899
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-wc-data-stage-methods
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add stage_*() methods for the WC_Data methods that save inside themselves. A stage_*() method changes the object in memory and does not save, so an extensible ability can make the change and run validators before one save. Direct calls to the existing saving methods keep their saves and hooks. When a class overrides a saving method without its stage_*() method, a direct call to the stage_*() method throws a WC_Data_Exception, and the class keeps working through the saving method.
diff --git a/plugins/woocommerce/changelog/fix-apply-coupon-usage-count b/plugins/woocommerce/changelog/fix-apply-coupon-usage-count
new file mode 100644
index 00000000000..972fc0ebe70
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-apply-coupon-usage-count
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+WC_Abstract_Order::apply_coupon() no longer counts the usage of the new coupon twice when the order's save records the coupon usage, for example after a status change to processing that was not saved yet.
diff --git a/plugins/woocommerce/changelog/fix-order-status-hooks-failed-save b/plugins/woocommerce/changelog/fix-order-status-hooks-failed-save
new file mode 100644
index 00000000000..0121fa20982
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-order-status-hooks-failed-save
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+WC_Order::save() no longer fires the order status change hooks when the order write fails. The next successful save of the same order object fires them once.
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
index 912530c80fa..6da0fd1f6cc 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-data.php
@@ -10,6 +10,8 @@
* @package WooCommerce\Classes
*/
+use Automattic\WooCommerce\Internal\Utilities\StagedMethod;
+
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
@@ -25,6 +27,15 @@ if ( ! defined( 'ABSPATH' ) ) {
*/
abstract class WC_Data {
+ /**
+ * Whether a stage_*() method runs for its saving method, so it saves and writes at the same points as the
+ * saving method always did. write_through() sets it.
+ *
+ * @since 11.4.0
+ * @var bool
+ */
+ protected $writing_through = false;
+
/**
* Clone mode constant: Duplicate mode clears meta IDs (default, for backward compatibility).
*
@@ -150,6 +161,43 @@ abstract class WC_Data {
$this->default_data = $this->data;
}
+ /**
+ * Run a stage_*() method for its saving method. While it runs, the stage method saves and writes at the
+ * same points, and fires its hooks in the same order, as the saving method always did. It also does not
+ * refuse the object, so a class that has not adopted the stage method keeps working.
+ *
+ * @since 11.4.0
+ * @param callable $stage Callback that calls the stage method.
+ * @return mixed What the stage method returns.
+ */
+ protected function write_through( callable $stage ) {
+ $writing_through = $this->writing_through;
+ $this->writing_through = true;
+ try {
+ return $stage();
+ } finally {
+ $this->writing_through = $writing_through;
+ }
+ }
+
+ /**
+ * Refuse a direct call of a stage_*() method when a class overrides its saving method without overriding the
+ * stage method too. Staging would skip the rules of that override. A saving method that runs its stage method
+ * through write_through() is not refused, so a class that has not adopted the stage method keeps working.
+ *
+ * @since 11.4.0
+ * @param string $method Saving method, for example add_product.
+ * @param string $stage_method Stage method, for example stage_product_item.
+ * @throws WC_Data_Exception When a class has not adopted the stage method.
+ */
+ protected function refuse_unstaged_override( string $method, string $stage_method ): void {
+ if ( $this->writing_through ) {
+ return;
+ }
+
+ StagedMethod::refuse_unstaged( $this, $method, $stage_method );
+ }
+
/**
* Only store the object ID to avoid serializing the data object instance.
*
diff --git a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
index 8828220a319..d32497c6622 100644
--- a/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
+++ b/plugins/woocommerce/includes/abstracts/abstract-wc-order.php
@@ -51,6 +51,41 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*/
private $last_save_error = null;
+ /**
+ * Callbacks that the next save() runs, keyed by name. Each entry holds the callback, its arguments, and
+ * whether it runs after the order is written. The stage_*() methods add them.
+ *
+ * @var array<string, array{callback: callable, args: array, after_save: bool}>
+ */
+ private $deferred_actions = array();
+
+ /**
+ * Saving methods and their stage methods, in the order the stage methods call each other. For example,
+ * stage_recalculated_coupons() calls stage_totals().
+ *
+ * @var array<string, string>
+ */
+ private const STAGED_CHAIN = array(
+ 'recalculate_coupons' => 'stage_recalculated_coupons',
+ 'calculate_totals' => 'stage_totals',
+ 'calculate_taxes' => 'stage_taxes',
+ 'update_taxes' => 'stage_tax_items',
+ );
+
+ /**
+ * Coupons that stage_coupon() applied since the last save, keyed by code. The next save() records their usage.
+ *
+ * @var WC_Coupon[]
+ */
+ private $staged_coupons = array();
+
+ /**
+ * Whether the order had recorded coupon usage when the save that records the staged coupons started.
+ *
+ * @var bool
+ */
+ private $coupon_usage_recorded_before_save = false;
+
/**
* Order Data array. This is the core order data exposed in APIs since 3.0.0.
*
@@ -228,6 +263,8 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*
* Orders use cache mode. A cached copy keeps its meta IDs, so it does not insert its meta again on save.
* Each copy gets its own meta, item, and date objects, so a change to one copy does not reach the others.
+ * A copy starts without the staged callbacks and coupons, so a save of the copy does not run them for the
+ * original order.
*
* Eventually, Core should stop unsetting meta IDs in WC_Data::__clone() for every subclass, and make
* duplication an explicit operation. Duplicating an object does not need to hook into __clone().
@@ -236,6 +273,8 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*/
public function __clone() {
parent::__clone();
+ $this->deferred_actions = array();
+ $this->staged_coupons = array();
foreach ( $this->items as $group => $items ) {
foreach ( $items as $key => $item ) {
$mode = $item->get_clone_mode();
@@ -308,6 +347,9 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->last_save_error = null;
+ $deferred_actions = $this->take_deferred_actions();
+ $this->run_deferred_actions( $deferred_actions, false );
+
try {
/**
* Trigger action before saving to the DB. Allows you to adjust object props before save.
@@ -348,6 +390,8 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
);
}
+ $this->run_deferred_actions( $deferred_actions, true );
+
return $this->get_id();
}
@@ -362,6 +406,118 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
return $this->last_save_error;
}
+ /**
+ * Refuse the stage methods that a stage method calls, from the given saving method to the end of the chain.
+ * A stage method calls this before it fires a hook or changes the order, so a refusal deeper in the chain
+ * does not leave a change half done.
+ *
+ * @since 11.4.0
+ * @param string $method First saving method of the chain to check, for example calculate_totals.
+ * @throws WC_Data_Exception When a class has not adopted one of the stage methods.
+ */
+ private function refuse_unstaged_chain( string $method ): void {
+ $chain = array_slice( self::STAGED_CHAIN, (int) array_search( $method, array_keys( self::STAGED_CHAIN ), true ), null, true );
+ foreach ( $chain as $saving_method => $stage_method ) {
+ $this->refuse_unstaged_override( $saving_method, $stage_method );
+ }
+ }
+
+ /**
+ * Leave a callback to the next save(), which runs it before or after the order is written. An entry with
+ * the same key replaces the earlier one, and a null callback removes it.
+ *
+ * @since 11.4.0
+ * @param string $key Name of the entry.
+ * @param callable|null $callback Callback to run, or null to remove the entry.
+ * @param array $args Arguments for the callback.
+ * @param bool $after_save True to run the callback after the order is written.
+ */
+ protected function defer_to_save( string $key, ?callable $callback, array $args = array(), bool $after_save = false ): void {
+ if ( null === $callback ) {
+ unset( $this->deferred_actions[ $key ] );
+ return;
+ }
+
+ $this->deferred_actions[ $key ] = array(
+ 'callback' => $callback,
+ 'args' => $args,
+ 'after_save' => $after_save,
+ );
+ }
+
+ /**
+ * Take the deferred callbacks and clear the list, so that each one runs for one save only. When the write
+ * fails, run_deferred_actions() puts the after-save callbacks back for the next save.
+ *
+ * @since 11.4.0
+ * @return array
+ */
+ protected function take_deferred_actions(): array {
+ $deferred_actions = $this->deferred_actions;
+ $this->deferred_actions = array();
+
+ return $deferred_actions;
+ }
+
+ /**
+ * Run the deferred callbacks that belong before or after the write. When the write fails, the callbacks
+ * that belong after it stay queued for the next save, so no hook reports a change that was not saved.
+ * A callback queued under the same key during the failed save replaces the old one.
+ *
+ * @since 11.4.0
+ * @param array $deferred_actions Entries from take_deferred_actions().
+ * @param bool $after_save True to run the entries that belong after the write.
+ */
+ protected function run_deferred_actions( array $deferred_actions, bool $after_save ): void {
+ if ( $after_save && null !== $this->last_save_error ) {
+ $this->deferred_actions += array_filter(
+ $deferred_actions,
+ static function ( array $entry ): bool {
+ return $entry['after_save'];
+ }
+ );
+ return;
+ }
+
+ foreach ( $deferred_actions as $entry ) {
+ if ( $entry['after_save'] === $after_save ) {
+ call_user_func_array( $entry['callback'], $entry['args'] );
+ }
+ }
+ }
+
+ /**
+ * Run a write or a hook right away when the stage method runs for its saving method, or leave it to the
+ * next save() otherwise.
+ *
+ * @since 11.4.0
+ * @param string $key Name of the deferred entry.
+ * @param callable $callback Callback to run.
+ * @param array $args Arguments for the callback.
+ * @param bool $after_save True to run a deferred callback after the order is written.
+ */
+ protected function write_or_defer( string $key, callable $callback, array $args = array(), bool $after_save = false ): void {
+ if ( $this->writing_through ) {
+ unset( $this->deferred_actions[ $key ] );
+ call_user_func_array( $callback, $args );
+ return;
+ }
+
+ $this->defer_to_save( $key, $callback, $args, $after_save );
+ }
+
+ /**
+ * Save the order when the stage method runs for its saving method, at the point where the saving method
+ * always saved. Do nothing otherwise.
+ *
+ * @since 11.4.0
+ */
+ protected function save_if_writing_through(): void {
+ if ( $this->writing_through ) {
+ $this->save();
+ }
+ }
+
/**
* Log an error about this order is exception is encountered.
*
@@ -1172,18 +1328,39 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* database deletion until the next call to save(). Custom stores overriding `delete_items()`
* without also overriding `delete_items_by_ids()` retain the historical synchronous behavior.
*
+ * @uses self::stage_order_items_removal()
* @param string|null $type Order item type. Default null (remove every type).
* @throws Exception If persisted item IDs cannot be read or synchronous item deletion fails.
* @return void
*/
public function remove_order_items( $type = null ) {
+ $this->write_through(
+ function () use ( $type ) {
+ $this->stage_order_items_removal( $type );
+ }
+ );
+ }
+
+ /**
+ * Remove line items from the order in memory, like remove_order_items(). For a custom store that deletes
+ * items synchronously, leave the deletion and woocommerce_removed_order_items to the next save(), which
+ * runs them before it writes. remove_order_items() runs this method through write_through(), so it deletes
+ * right away, as it always did.
+ *
+ * @since 11.4.0
+ * @param string|null $type Order item type. Default null (remove every type).
+ * @throws Exception If persisted item IDs cannot be read or synchronous item deletion fails.
+ * @return void
+ */
+ public function stage_order_items_removal( $type = null ) {
+ $this->refuse_unstaged_override( 'remove_order_items', 'stage_order_items_removal' );
// Guard against extension code passing non-string values. Anything not
// a string or null is ignored so it never reaches the deferred queue or
// the data store. Surface the misuse so it's traceable rather than silent.
if ( null !== $type && ! is_string( $type ) ) {
wc_doing_it_wrong(
- __METHOD__,
+ __CLASS__ . '::remove_order_items',
sprintf( esc_html( 'remove_order_items() expects a string item type or null; received %s.' ), esc_html( gettype( $type ) ) ),
'11.0.0'
);
@@ -1205,8 +1382,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$delete_synchronously = ! $this->data_store_supports_deferred_item_deletion();
if ( $delete_synchronously && $has_persisted_items ) {
- // @phpstan-ignore-next-line -- Required order data store method forwarded by WC_Data_Store::__call().
- $this->data_store->delete_items( $this, $type );
+ $this->write_or_defer( 'delete_order_items_' . (string) $type, array( $this, 'delete_items_synchronously' ), array( $type ) );
}
if ( ! empty( $type ) ) {
@@ -1271,15 +1447,35 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
}
if ( $delete_synchronously ) {
- /**
- * This action is documented in save_items().
- *
- * @since 7.8.0
- */
- do_action( 'woocommerce_removed_order_items', $this, $type );
+ $this->write_or_defer( 'removed_order_items_' . (string) $type, array( $this, 'fire_removed_order_items_action' ), array( $type ) );
}
}
+ /**
+ * Delete order items through a custom store that does not defer the deletion.
+ *
+ * @param string|null $type Order item type, or null for every type.
+ * @throws Exception If synchronous item deletion fails.
+ */
+ private function delete_items_synchronously( $type ): void {
+ // @phpstan-ignore-next-line -- Required order data store method forwarded by WC_Data_Store::__call().
+ $this->data_store->delete_items( $this, $type );
+ }
+
+ /**
+ * Fire woocommerce_removed_order_items after a synchronous deletion.
+ *
+ * @param string|null $type Order item type, or null for every type.
+ */
+ private function fire_removed_order_items_action( $type ): void {
+ /**
+ * This action is documented in save_items().
+ *
+ * @since 7.8.0
+ */
+ do_action( 'woocommerce_removed_order_items', $this, $type );
+ }
+
/**
* Convert a type to a types group.
*
@@ -1735,10 +1931,31 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* Apply a coupon to the order and recalculate totals.
*
* @since 3.2.0
+ * @uses self::stage_coupon()
* @param string|WC_Coupon $raw_coupon Coupon code or object.
* @return true|WP_Error True if applied, error if not.
*/
public function apply_coupon( $raw_coupon ) {
+ return $this->write_through(
+ function () use ( $raw_coupon ) {
+ return $this->stage_coupon( $raw_coupon );
+ }
+ );
+ }
+
+ /**
+ * Apply a coupon to the order and recalculate totals in memory, like apply_coupon(), but do not save. The
+ * next save() writes the order and then records the coupon usage. apply_coupon() runs this method through
+ * write_through(), so it saves and records the usage at the same points as it always did.
+ *
+ * @since 11.4.0
+ * @param string|WC_Coupon $raw_coupon Coupon code or object.
+ * @return true|WP_Error True if applied, error if not.
+ */
+ public function stage_coupon( $raw_coupon ) {
+ $this->refuse_unstaged_override( 'apply_coupon', 'stage_coupon' );
+ $this->refuse_unstaged_chain( 'recalculate_coupons' );
+
if ( is_a( $raw_coupon, 'WC_Coupon' ) ) {
$coupon = $raw_coupon;
} elseif ( is_string( $raw_coupon ) ) {
@@ -1795,6 +2012,12 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
/**
* Action to signal that a coupon has been applied to an order.
*
+ * Fires before the order is saved and before the coupon totals are recalculated. A listener can change
+ * the order in memory, for example add a line item, and the new totals include that change. After
+ * stage_coupon(), the order might never be saved: a validator can refuse the change, or
+ * stage_coupon_removal() can remove the coupon before the save. Keep the listener to in-memory changes,
+ * and do not write to the database from it.
+ *
* @param WC_Coupon $coupon The applied coupon object.
* @param WC_Order $order The current order object.
*
@@ -1803,18 +2026,36 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
do_action( 'woocommerce_order_applied_coupon', $coupon, $this );
$this->set_coupon_discount_amounts( $discounts );
- $this->save();
+ $recorded_before_save = $this->writing_through && $this->has_recorded_coupon_usage();
+ $this->save_if_writing_through();
// Recalculate totals and taxes.
- $this->recalculate_coupons();
-
- // Record usage so counts and validation is correct.
- $used_by = $this->get_user_id();
+ if ( $this->writing_through ) {
+ $this->recalculate_coupons();
+ } else {
+ $this->stage_recalculated_coupons();
+ }
- if ( ! $used_by && is_callable( array( $this, 'get_billing_email' ) ) ) {
- $used_by = $this->get_billing_email();
+ if ( $this->writing_through ) {
+ if ( $recorded_before_save && $this->has_recorded_coupon_usage() ) {
+ $this->increase_coupon_usage( array( $coupon ) );
+ }
+ wc_update_coupon_usage_counts( $this->get_id() );
+ } else {
+ $this->staged_coupons[ $coupon->get_code() ] = $coupon;
+ $this->defer_to_save( 'read_coupon_usage_before_save', array( $this, 'read_coupon_usage_before_save' ) );
+ $this->defer_to_save( 'record_staged_coupon_usage', array( $this, 'record_staged_coupon_usage' ), array(), true );
}
+ return true;
+ }
+
+ /**
+ * Whether the stored order has its coupon usage recorded.
+ *
+ * @return bool
+ */
+ private function has_recorded_coupon_usage(): bool {
/**
* Data store wrapper.
*
@@ -1822,16 +2063,57 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*/
$order_data_store = $this->get_data_store();
// @phpstan-ignore-next-line method.notFound (Guarded by has_callable() and called via __call() on the underlying order data store instance.)
- if ( $order_data_store->has_callable( 'get_recorded_coupon_usage_counts' ) && $order_data_store->get_recorded_coupon_usage_counts( $this ) ) {
+ return $order_data_store->has_callable( 'get_recorded_coupon_usage_counts' ) && $order_data_store->get_recorded_coupon_usage_counts( $this );
+ }
+
+ /**
+ * Remember whether the order had recorded coupon usage before the write. The write can record the usage of
+ * every coupon, the staged ones included, through a status change.
+ */
+ private function read_coupon_usage_before_save(): void {
+ $this->coupon_usage_recorded_before_save = $this->has_recorded_coupon_usage();
+ }
+
+ /**
+ * Record the usage of the staged coupons once, against the coupons of the saved order. When the order
+ * had recorded usage before the write and still has it after, count the staged coupons, which that usage
+ * did not include.
+ * wc_update_coupon_usage_counts() then counts every coupon of an order that has not recorded usage yet.
+ */
+ private function record_staged_coupon_usage(): void {
+ $coupons = $this->staged_coupons;
+ $this->staged_coupons = array();
+
+ if ( $this->coupon_usage_recorded_before_save && $this->has_recorded_coupon_usage() ) {
+ $this->increase_coupon_usage( $coupons );
+ }
+
+ wc_update_coupon_usage_counts( $this->get_id() );
+ }
+
+ /**
+ * Increase the usage count of applied coupons, and store the identity each one was counted under.
+ *
+ * @param WC_Coupon[] $coupons Applied coupons.
+ */
+ private function increase_coupon_usage( array $coupons ): void {
+ $used_by = $this->get_user_id();
+
+ if ( ! $used_by && is_callable( array( $this, 'get_billing_email' ) ) ) {
+ $used_by = $this->get_billing_email();
+ }
+
+ $identities = array();
+ foreach ( $coupons as $coupon ) {
$coupon->increase_usage_count( $used_by );
if ( $coupon->get_id() ) {
- $this->save_coupon_usage_identities( array( $coupon->get_id() => $used_by ) + $this->get_coupon_usage_identities() );
+ $identities[ $coupon->get_id() ] = $used_by;
}
}
- wc_update_coupon_usage_counts( $this->get_id() );
-
- return true;
+ if ( ! empty( $identities ) ) {
+ $this->save_coupon_usage_identities( $identities + $this->get_coupon_usage_identities() );
+ }
}
/**
@@ -1948,19 +2230,34 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* @since 3.2.0
* @since 7.6.0 Returns a boolean indicating success.
*
+ * @uses self::stage_coupon_removal()
* @param string $code Coupon code.
* @return bool TRUE if coupon was removed, FALSE otherwise.
*/
public function remove_coupon( $code ) {
- $coupons = $this->get_items( 'coupon' );
- /**
- * Data store wrapper.
- *
- * @var WC_Data_Store $order_data_store
- */
- $order_data_store = $this->get_data_store();
- // @phpstan-ignore-next-line method.notFound (Guarded by has_callable() and called via __call() on the underlying order data store instance.)
- $has_recorded = $order_data_store->has_callable( 'get_recorded_coupon_usage_counts' ) && $order_data_store->get_recorded_coupon_usage_counts( $this );
+ return $this->write_through(
+ function () use ( $code ) {
+ return $this->stage_coupon_removal( $code );
+ }
+ );
+ }
+
+ /**
+ * Remove a coupon from the order and recalculate totals in memory, like remove_coupon(), but do not save.
+ * The next save() lowers the coupon usage count before it writes, and fires woocommerce_order_removed_coupon
+ * after it writes. remove_coupon() runs this method through write_through(), so it does both at the same
+ * points as it always did.
+ *
+ * @since 11.4.0
+ * @param string $code Coupon code.
+ * @return bool True if the coupon was removed, false otherwise.
+ */
+ public function stage_coupon_removal( $code ) {
+ $this->refuse_unstaged_override( 'remove_coupon', 'stage_coupon_removal' );
+ $this->refuse_unstaged_chain( 'recalculate_coupons' );
+
+ $coupons = $this->get_items( 'coupon' );
+ $has_recorded = $this->has_recorded_coupon_usage();
$used_by = $this->get_user_id();
if ( ! $used_by && is_callable( array( $this, 'get_billing_email' ) ) ) {
@@ -1973,14 +2270,22 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->remove_item( $this->get_stored_item_key( $coupon ) );
$coupon_object = new WC_Coupon( $code );
+ $staged = isset( $this->staged_coupons[ $coupon_object->get_code() ] );
+ if ( $staged ) {
+ unset( $this->staged_coupons[ $coupon_object->get_code() ] );
+ if ( empty( $this->staged_coupons ) ) {
+ $this->defer_to_save( 'read_coupon_usage_before_save', null );
+ $this->defer_to_save( 'record_staged_coupon_usage', null );
+ }
+ }
+
if ( $has_recorded ) {
// Mirror the stored flag into this instance so clearing it below is tracked as a change and saved.
$this->set_recorded_coupon_usage_counts( true );
- $identities = $this->get_coupon_usage_identities();
- $coupon_object->decrease_usage_count( $identities[ $coupon_object->get_id() ] ?? $used_by );
- unset( $identities[ $coupon_object->get_id() ] );
- $this->save_coupon_usage_identities( $identities );
+ if ( ! $staged ) {
+ $this->write_or_defer( 'release_coupon_usage_' . $coupon_object->get_code(), array( $this, 'release_removed_coupon_usage' ), array( $coupon_object, $used_by ) );
+ }
$remaining_codes = array_filter( $this->get_coupon_codes(), fn( $remaining_code ) => ! StringUtil::is_null_or_whitespace( $remaining_code ) );
if ( ! $remaining_codes ) {
@@ -1988,17 +2293,13 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
}
}
- $this->recalculate_coupons();
+ if ( $this->writing_through ) {
+ $this->recalculate_coupons();
+ } else {
+ $this->stage_recalculated_coupons();
+ }
- /**
- * Action hook fired when a coupon is removed from an order.
- *
- * @param WC_Coupon $coupon_object The removed coupon object.
- * @param WC_Order $order The current order object.
- *
- * @since 10.8.0
- */
- do_action( 'woocommerce_order_removed_coupon', $coupon_object, $this );
+ $this->write_or_defer( 'removed_coupon_' . $coupon_object->get_code(), array( $this, 'fire_removed_coupon_action' ), array( $coupon_object ), true );
return true;
}
@@ -2007,6 +2308,40 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
return false;
}
+ /**
+ * Lower the usage count of a removed coupon, and forget the identity it was counted under.
+ *
+ * @param WC_Coupon $coupon_object Removed coupon.
+ * @param int|string $used_by User ID or billing email to use when no identity was stored.
+ */
+ private function release_removed_coupon_usage( WC_Coupon $coupon_object, $used_by ): void {
+ $identities = $this->get_coupon_usage_identities();
+ $coupon_object->decrease_usage_count( $identities[ $coupon_object->get_id() ] ?? $used_by );
+ unset( $identities[ $coupon_object->get_id() ] );
+ $this->save_coupon_usage_identities( $identities );
+ }
+
+ /**
+ * Fire woocommerce_order_removed_coupon for a removed coupon.
+ *
+ * @param WC_Coupon $coupon_object Removed coupon.
+ */
+ private function fire_removed_coupon_action( WC_Coupon $coupon_object ): void {
+ /**
+ * Action hook fired when a coupon is removed from an order.
+ *
+ * Fires after the order is saved. After stage_coupon_removal(), it fires from the next save(). It also
+ * fires for a coupon that stage_coupon() applied and that was removed before any save, so a listener
+ * can undo what it did on woocommerce_order_applied_coupon.
+ *
+ * @param WC_Coupon $coupon_object The removed coupon object.
+ * @param WC_Order $order The current order object.
+ *
+ * @since 10.8.0
+ */
+ do_action( 'woocommerce_order_removed_coupon', $coupon_object, $this );
+ }
+
/**
* Get the identity each coupon was counted under, keyed by coupon ID.
*
@@ -2047,9 +2382,28 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* This method is public since WooCommerce 3.8.0.
*
* @since 3.2.0
+ * @uses self::stage_recalculated_coupons()
* @return void
*/
public function recalculate_coupons() {
+ $this->write_through(
+ function () {
+ $this->stage_recalculated_coupons();
+ }
+ );
+ }
+
+ /**
+ * Apply all coupons in this order again to all line items and recalculate totals and taxes in memory, like
+ * recalculate_coupons(), but do not save. recalculate_coupons() runs this method through write_through().
+ *
+ * @since 11.4.0
+ * @return void
+ */
+ public function stage_recalculated_coupons() {
+ $this->refuse_unstaged_override( 'recalculate_coupons', 'stage_recalculated_coupons' );
+ $this->refuse_unstaged_chain( 'calculate_totals' );
+
// Reset line item totals.
foreach ( $this->get_items() as $item ) {
$item->set_total( $item->get_subtotal() );
@@ -2102,7 +2456,11 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->set_item_discount_amounts( $discounts );
// Recalculate totals and taxes.
- $this->calculate_totals( true );
+ if ( $this->writing_through ) {
+ $this->calculate_totals( true );
+ } else {
+ $this->stage_totals( true );
+ }
}
/**
@@ -2189,9 +2547,13 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
if ( $coupon_discounts ) {
foreach ( $coupon_discounts as $coupon_code => $amount ) {
- $item_id = isset( $coupon_code_to_id[ $coupon_code ] ) ? $coupon_code_to_id[ $coupon_code ] : 0;
+ $item_id = isset( $coupon_code_to_id[ $coupon_code ] ) ? $coupon_code_to_id[ $coupon_code ] : 0;
+ $unsaved_item = $item_id || $this->writing_through ? null : $this->get_unsaved_coupon_item( $coupon_code );
+ $reuse_unsaved = null !== $unsaved_item;
- if ( ! $item_id ) {
+ if ( $reuse_unsaved ) {
+ $coupon_item = $unsaved_item;
+ } elseif ( ! $item_id ) {
$coupon_item = new WC_Order_Item_Coupon();
$coupon_item->set_code( $coupon_code );
@@ -2230,21 +2592,64 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$coupon_item->set_discount( $amount );
$coupon_item->set_discount_tax( $discount_tax );
- $this->add_item( $coupon_item );
+ if ( ! $reuse_unsaved ) {
+ $this->add_item( $coupon_item );
+ }
+ }
+ }
+ }
+
+ /**
+ * Find the unsaved coupon item for a code. A staged coupon has no ID until the order is saved.
+ *
+ * @param string $coupon_code Coupon code.
+ * @return WC_Order_Item_Coupon|null
+ */
+ private function get_unsaved_coupon_item( string $coupon_code ): ?WC_Order_Item_Coupon {
+ foreach ( $this->get_items( 'coupon' ) as $coupon_item ) {
+ if ( $coupon_item instanceof WC_Order_Item_Coupon && ! $coupon_item->get_id() && $coupon_item->get_code() === $coupon_code ) {
+ return $coupon_item;
}
}
+
+ return null;
}
/**
* Add a product line item to the order. This is the only line item type with
* its own method because it saves looking up order amounts (costs are added up for you).
*
+ * @uses self::stage_product_item()
* @param WC_Product $product Product object.
* @param int $qty Quantity to add.
* @param array $args Args for the added product, including an optional tax location for its price.
* @return int
*/
public function add_product( $product, $qty = 1, $args = array() ) {
+ return $this->write_through(
+ function () use ( $product, $qty, $args ) {
+ return $this->stage_product_item( $product, $qty, $args );
+ }
+ )->get_id();
+ }
+
+ /**
+ * Build a product line item and add it to the order in memory, like add_product(), but do not save the
+ * item. The next save() of the order writes it. add_product() runs this method through write_through(), so
+ * it saves the item first, as it always did.
+ *
+ * An order class that overrides add_product() with rules of its own must override this method too, with
+ * the same rules.
+ *
+ * @since 11.4.0
+ * @param WC_Product $product Product object.
+ * @param int $qty Quantity to add.
+ * @param array $args Args for the added product, including an optional tax location for its price.
+ * @return WC_Order_Item_Product The item, saved only when add_product() runs this method.
+ */
+ public function stage_product_item( $product, $qty = 1, $args = array() ) {
+ $this->refuse_unstaged_override( 'add_product', 'stage_product_item' );
+
if ( $product ) {
$order = ArrayUtil::get_value_or_default( $args, 'order' );
$total = wc_get_price_excluding_tax(
@@ -2291,11 +2696,30 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$item->set_props( $args );
$item->set_backorder_meta();
$item->set_order_id( $this->get_id() );
- $item->save();
+ if ( $this->writing_through ) {
+ $item->save();
+ }
$this->add_item( $item );
- wc_do_deprecated_action( 'woocommerce_order_add_product', array( $this->get_id(), $item->get_id(), $product, $qty, $args ), '3.0', 'woocommerce_new_order_item action instead' );
+ $this->write_or_defer( 'add_product_action_' . spl_object_id( $item ), array( $this, 'fire_add_product_action' ), array( $item, $product, $qty, $args ), true );
wp_cache_delete( 'order-needs-processing-' . $this->get_id(), 'orders' );
- return $item->get_id();
+ return $item;
+ }
+
+ /**
+ * Fire the deprecated woocommerce_order_add_product action for a saved product item. Skip an item that the
+ * order removed before the save, because save() never wrote it.
+ *
+ * @param WC_Order_Item_Product $item Saved item.
+ * @param WC_Product|false $product Product object.
+ * @param int $qty Quantity added.
+ * @param array $args Args for the added product, after the defaults.
+ */
+ private function fire_add_product_action( $item, $product, $qty, $args ): void {
+ if ( ! $item->get_id() ) {
+ return;
+ }
+
+ wc_do_deprecated_action( 'woocommerce_order_add_product', array( $this->get_id(), $item->get_id(), $product, $qty, $args ), '3.0', 'woocommerce_new_order_item action instead' );
}
/*
@@ -2350,9 +2774,29 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* Calculate shipping total.
*
* @since 2.2
+ * @uses self::stage_shipping_total()
* @return float
*/
public function calculate_shipping() {
+ $this->write_through(
+ function () {
+ $this->stage_shipping_total();
+ }
+ );
+
+ return $this->get_shipping_total();
+ }
+
+ /**
+ * Calculate the shipping total in memory, like calculate_shipping(), but do not save.
+ * calculate_shipping() runs this method through write_through().
+ *
+ * @since 11.4.0
+ * @return void
+ */
+ public function stage_shipping_total() {
+ $this->refuse_unstaged_override( 'calculate_shipping', 'stage_shipping_total' );
+
$shipping_total = 0;
foreach ( $this->get_shipping_methods() as $shipping ) {
@@ -2360,9 +2804,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
}
$this->set_shipping_total( $shipping_total );
- $this->save();
-
- return $this->get_shipping_total();
+ $this->save_if_writing_through();
}
/**
@@ -2493,10 +2935,30 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
*
* Will use the base country unless customer addresses are set.
*
+ * @uses self::stage_taxes()
* @param array $args Added in 3.0.0 to pass things like location.
* @return void
*/
public function calculate_taxes( $args = array() ) {
+ $this->write_through(
+ function () use ( $args ) {
+ $this->stage_taxes( $args );
+ }
+ );
+ }
+
+ /**
+ * Calculate the taxes of the items and update the tax items in memory, like calculate_taxes(), but do not
+ * save. calculate_taxes() runs this method through write_through().
+ *
+ * @since 11.4.0
+ * @param array $args Override the tax location.
+ * @return void
+ */
+ public function stage_taxes( $args = array() ) {
+ $this->refuse_unstaged_override( 'calculate_taxes', 'stage_taxes' );
+ $this->refuse_unstaged_chain( 'update_taxes' );
+
do_action( 'woocommerce_order_before_calculate_taxes', $args, $this );
$calculate_tax_for = $this->get_tax_location( $args );
@@ -2531,7 +2993,11 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
}
}
- $this->update_taxes();
+ if ( $this->writing_through ) {
+ $this->update_taxes();
+ } else {
+ $this->stage_tax_items();
+ }
}
/**
@@ -2552,9 +3018,28 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
/**
* Update tax lines for the order based on the line item taxes themselves.
*
+ * @uses self::stage_tax_items()
* @return void
*/
public function update_taxes() {
+ $this->write_through(
+ function () {
+ $this->stage_tax_items();
+ }
+ );
+ }
+
+ /**
+ * Update the tax items of the order from the item taxes in memory, like update_taxes(), but do not save.
+ * The next save() writes the changed tax items before it writes the order. update_taxes() runs this method
+ * through write_through(), so it saves at the same points as it always did.
+ *
+ * @since 11.4.0
+ * @return void
+ */
+ public function stage_tax_items() {
+ $this->refuse_unstaged_override( 'update_taxes', 'stage_tax_items' );
+
$cart_taxes = array();
$shipping_taxes = array();
$existing_taxes = $this->get_taxes();
@@ -2603,7 +3088,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$tax->set_shipping_tax_total( ! empty( $shipping_taxes[ $tax_rate_id ] ) ? $shipping_taxes[ $tax_rate_id ] : 0 );
$saved_rate_ids[] = $tax_rate_id;
- $tax->save();
+ $this->write_or_defer( 'save_tax_item_' . spl_object_id( $tax ), array( $tax, 'save' ) );
}
$new_rate_ids = wp_parse_id_list( array_diff( array_keys( $cart_taxes + $shipping_taxes ), $saved_rate_ids ) );
@@ -2626,7 +3111,7 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->set_shipping_tax( array_sum( $shipping_taxes ) );
$this->set_cart_tax( array_sum( $cart_taxes ) );
- $this->save();
+ $this->save_if_writing_through();
}
/**
@@ -2661,10 +3146,37 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
* Calculate totals by looking at the contents of the order. Stores the totals and returns the orders final total.
*
* @since 2.2
+ * @uses self::stage_totals()
* @param bool $and_taxes Calc taxes if true.
* @return float calculated grand total.
*/
public function calculate_totals( $and_taxes = true ) {
+ return $this->write_through(
+ function () use ( $and_taxes ) {
+ return $this->stage_totals( $and_taxes );
+ }
+ );
+ }
+
+ /**
+ * Calculate totals, and taxes when asked, in memory, like calculate_totals(), but do not save.
+ * calculate_totals() runs this method through write_through(), so it saves at the same points as it
+ * always did.
+ *
+ * Called directly, this method leaves woocommerce_order_after_calculate_totals to the next save(), which
+ * fires it right before the write, so a listener that changes the order gets its change written. The new
+ * tax items have no IDs yet when the action fires; read them from the order object, not from the database.
+ *
+ * @since 11.4.0
+ * @param bool $and_taxes Calc taxes if true.
+ * @return float calculated grand total.
+ */
+ public function stage_totals( $and_taxes = true ) {
+ $this->refuse_unstaged_override( 'calculate_totals', 'stage_totals' );
+ if ( $and_taxes ) {
+ $this->refuse_unstaged_chain( 'calculate_taxes' );
+ }
+
do_action( 'woocommerce_order_before_calculate_totals', $and_taxes, $this );
$fees_total = 0;
@@ -2697,9 +3209,11 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$fees_total += (float) $item->get_total();
}
- // Calculate taxes for items, shipping, discounts. Note; this also triggers save().
- if ( $and_taxes ) {
+ // Calculate taxes for items, shipping, discounts. Note; when writing through, this also triggers save().
+ if ( $and_taxes && $this->writing_through ) {
$this->calculate_taxes();
+ } elseif ( $and_taxes ) {
+ $this->stage_taxes();
}
// Sum taxes again so we can work out how much tax was discounted. This uses original values, not those possibly rounded to 2dp.
@@ -2723,9 +3237,17 @@ abstract class WC_Abstract_Order extends WC_Abstract_Legacy_Order {
$this->calculate_cogs_total_value();
}
- do_action( 'woocommerce_order_after_calculate_totals', $and_taxes, $this );
+ /**
+ * Fires after the order totals are calculated.
+ *
+ * @param bool $and_taxes Whether the taxes were calculated too.
+ * @param WC_Abstract_Order $order Order object.
+ * @param bool $staged Whether the totals were staged and are not yet saved. Always passed; a listener that wants it declares it with a default. When true, the order and its tax items are in memory and have no IDs yet; use the $order object, not the database. @since 11.4.0
+ * @since 3.3.0
+ */
+ $this->write_or_defer( 'after_calculate_totals', 'do_action', array( 'woocommerce_order_after_calculate_totals', $and_taxes, $this, ! $this->writing_through ) );
- $this->save();
+ $this->save_if_writing_through();
return $this->get_total();
}
diff --git a/plugins/woocommerce/includes/class-wc-discounts.php b/plugins/woocommerce/includes/class-wc-discounts.php
index 9da858cfb61..6e6925d5722 100644
--- a/plugins/woocommerce/includes/class-wc-discounts.php
+++ b/plugins/woocommerce/includes/class-wc-discounts.php
@@ -109,9 +109,9 @@ class WC_Discounts {
$this->object = $order;
- foreach ( $order->get_items() as $order_item ) {
+ foreach ( $order->get_items() as $stored_key => $order_item ) {
$item = new stdClass();
- $item->key = $order_item->get_id();
+ $item->key = $order_item->get_id() ? $order_item->get_id() : $stored_key;
$item->object = $order_item;
$item->product = $order_item->get_product();
$item->quantity = $order_item->get_quantity();
@@ -121,7 +121,7 @@ class WC_Discounts {
$item->price += wc_add_number_precision_deep( $order_item->get_subtotal_tax() );
}
- $this->items[ $order_item->get_id() ] = $item;
+ $this->items[ $item->key ] = $item;
}
uasort( $this->items, array( $this, 'sort_by_price' ) );
diff --git a/plugins/woocommerce/includes/class-wc-order-refund.php b/plugins/woocommerce/includes/class-wc-order-refund.php
index c00fe93b7b1..92a0d578511 100644
--- a/plugins/woocommerce/includes/class-wc-order-refund.php
+++ b/plugins/woocommerce/includes/class-wc-order-refund.php
@@ -156,13 +156,16 @@ class WC_Order_Refund extends WC_Abstract_Order {
}
/**
- * Coupons cannot be applied to refunds, so a refund never records coupon usage.
+ * Coupons cannot be applied to refunds, so a refund never records coupon usage. apply_coupon() calls this
+ * method, so it refuses the coupon too.
*
- * @since 11.3.0
+ * @since 11.4.0
* @param string|WC_Coupon $raw_coupon Coupon code or object.
* @return WP_Error
*/
- public function apply_coupon( $raw_coupon ) {
+ public function stage_coupon( $raw_coupon ) {
+ $this->refuse_unstaged_override( 'apply_coupon', 'stage_coupon' );
+
return new WP_Error( 'invalid_coupon', __( 'Coupons cannot be applied to refunds.', 'woocommerce' ) );
}
diff --git a/plugins/woocommerce/includes/class-wc-order.php b/plugins/woocommerce/includes/class-wc-order.php
index 3e074562827..0e897dc7f85 100644
--- a/plugins/woocommerce/includes/class-wc-order.php
+++ b/plugins/woocommerce/includes/class-wc-order.php
@@ -36,13 +36,6 @@ class WC_Order extends WC_Abstract_Order {
*/
private $staging_status = false;
- /**
- * Status that the next save() fires woocommerce_order_edit_status for, or null when there is none.
- *
- * @var string|null
- */
- private $pending_edit_status = null;
-
/**
* Order Data array. This is the core order data exposed in APIs since 3.0.0.
*
@@ -146,6 +139,7 @@ class WC_Order extends WC_Abstract_Order {
* Sales are also recorded for products.
* Finally, record the date of payment.
*
+ * @uses self::stage_payment_complete()
* @param string $transaction_id Optional transaction id to store in post meta.
* @return bool success
*/
@@ -155,52 +149,11 @@ class WC_Order extends WC_Abstract_Order {
}
try {
- do_action( 'woocommerce_pre_payment_complete', $this->get_id(), $transaction_id );
-
- if ( WC()->session ) {
- WC()->session->set( 'order_awaiting_payment', false );
- }
-
- /**
- * Filters the valid order statuses for payment complete.
- *
- * @param array $valid_completed_statuses Array of valid order statuses for payment complete.
- * @param WC_Order $this Order object.
- * @since 2.7.0
- */
- $valid_completed_statuses = apply_filters( 'woocommerce_valid_order_statuses_for_payment_complete', OrderStatus::PAYMENT_COMPLETE_STATUSES, $this );
- if ( $this->has_status( $valid_completed_statuses ) ) {
- if ( ! empty( $transaction_id ) ) {
- $this->set_transaction_id( $transaction_id );
- }
- if ( ! $this->get_date_paid( 'edit' ) ) {
- $this->set_date_paid( time() );
+ $this->write_through(
+ function () use ( $transaction_id ) {
+ return $this->stage_payment_complete( $transaction_id );
}
-
- /**
- * Filters the order status to set after payment complete.
- *
- * @param string $status Order status.
- * @param int $order_id Order ID.
- * @param WC_Order $this Order object.
- * @since 2.7.0
- */
- $next_status = apply_filters( 'woocommerce_payment_complete_order_status', $this->needs_processing() ? OrderStatus::PROCESSING : OrderStatus::COMPLETED, $this->get_id(), $this );
-
- $transaction_id = $this->get_transaction_id();
- $payment_method = $this->get_payment_method_title();
-
- // translators: %1$s is the payment method, %2$s is the transaction id.
- $payment_complete_note = $payment_method && $transaction_id ? sprintf( __( 'Payment via %1$s (%2$s).', 'woocommerce' ), $payment_method, $transaction_id ) : __( 'Payment complete.', 'woocommerce' );
-
- $this->set_status( $next_status, false );
- $this->add_order_note( $payment_complete_note, false, false, array( 'note_group' => OrderNoteGroup::PAYMENT ) );
- $this->save();
-
- do_action( 'woocommerce_payment_complete', $this->get_id(), $transaction_id );
- } else {
- do_action( 'woocommerce_payment_complete_order_status_' . $this->get_status(), $this->get_id(), $transaction_id );
- }
+ );
} catch ( Exception $e ) {
/**
* If there was an error completing the payment, log to a file and add an order note so the admin can take action.
@@ -222,6 +175,96 @@ class WC_Order extends WC_Abstract_Order {
return true;
}
+ /**
+ * Make the in-memory changes of payment_complete(): the transaction ID, the paid date and the payment
+ * complete status. Leave the payment note and woocommerce_payment_complete to the next save(), which adds
+ * the note before it writes and fires the action after it writes. payment_complete() runs this method
+ * through write_through(), so it adds the note, saves and fires the action right away, as it always did.
+ *
+ * When the order status is not one that payment can complete, the order does not change, the
+ * woocommerce_payment_complete_order_status_<status> action fires right away, and the method returns false.
+ *
+ * An order that is not saved yet does not change, and the method returns false.
+ *
+ * An order class that overrides payment_complete() with rules of its own must override this method too,
+ * with the same rules.
+ *
+ * @since 11.4.0
+ * @param string $transaction_id Optional transaction ID to store.
+ * @return bool True when the order has changes for save() to write.
+ */
+ public function stage_payment_complete( $transaction_id = '' ) {
+ $this->refuse_unstaged_override( 'payment_complete', 'stage_payment_complete' );
+
+ if ( ! $this->get_id() ) {
+ return false;
+ }
+
+ /**
+ * Fires before the payment of an order is completed.
+ *
+ * @param int $order_id Order ID.
+ * @param string $transaction_id Transaction ID passed to payment_complete().
+ * @since 2.1.0
+ */
+ do_action( 'woocommerce_pre_payment_complete', $this->get_id(), $transaction_id );
+
+ if ( WC()->session ) {
+ WC()->session->set( 'order_awaiting_payment', false );
+ }
+
+ /**
+ * Filters the valid order statuses for payment complete.
+ *
+ * @param array $valid_completed_statuses Array of valid order statuses for payment complete.
+ * @param WC_Order $this Order object.
+ * @since 2.7.0
+ */
+ $valid_completed_statuses = apply_filters( 'woocommerce_valid_order_statuses_for_payment_complete', OrderStatus::PAYMENT_COMPLETE_STATUSES, $this );
+ if ( ! $this->has_status( $valid_completed_statuses ) ) {
+ /**
+ * Fires when payment completes for an order whose status payment cannot complete.
+ *
+ * @param int $order_id Order ID.
+ * @param string $transaction_id Transaction ID passed to payment_complete().
+ * @since 2.1.0
+ */
+ do_action( 'woocommerce_payment_complete_order_status_' . $this->get_status(), $this->get_id(), $transaction_id );
+ return false;
+ }
+
+ if ( ! empty( $transaction_id ) ) {
+ $this->set_transaction_id( $transaction_id );
+ }
+ if ( ! $this->get_date_paid( 'edit' ) ) {
+ $this->set_date_paid( time() );
+ }
+
+ /**
+ * Filters the order status to set after payment complete.
+ *
+ * @param string $status Order status.
+ * @param int $order_id Order ID.
+ * @param WC_Order $this Order object.
+ * @since 2.7.0
+ */
+ $next_status = apply_filters( 'woocommerce_payment_complete_order_status', $this->needs_processing() ? OrderStatus::PROCESSING : OrderStatus::COMPLETED, $this->get_id(), $this );
+
+ $transaction_id = $this->get_transaction_id();
+ $payment_method = $this->get_payment_method_title();
+
+ // translators: %1$s is the payment method, %2$s is the transaction id.
+ $payment_complete_note = $payment_method && $transaction_id ? sprintf( __( 'Payment via %1$s (%2$s).', 'woocommerce' ), $payment_method, $transaction_id ) : __( 'Payment complete.', 'woocommerce' );
+
+ $this->set_status( $next_status, false );
+
+ $this->write_or_defer( 'payment_complete_note', array( $this, 'add_order_note' ), array( $payment_complete_note, false, false, array( 'note_group' => OrderNoteGroup::PAYMENT ) ) );
+ $this->save_if_writing_through();
+ $this->write_or_defer( 'payment_complete', 'do_action', array( 'woocommerce_payment_complete', $this->get_id(), $transaction_id ), true );
+
+ return true;
+ }
+
/**
* Gets order total - formatted for display.
*
@@ -291,31 +334,22 @@ class WC_Order extends WC_Abstract_Order {
/**
* Save data to the database.
*
+ * When the write fails, the status change hooks do not fire, and the next successful save fires them.
+ *
* @since 3.0.0
* @return int order ID
*/
public function save() {
- $status = $this->pending_edit_status;
- $this->pending_edit_status = null;
-
- if ( null !== $status ) {
- /**
- * Fires for a manual status change of an order, before the change
- * is written, so a listener that reloads the order sees the old row.
- * After set_status(), it fires before the paid and completed dates
- * are set. After stage_status() or update_status(), it fires from
- * save(), with those dates already set on the order object.
- *
- * @since 3.0.0
- * @param int $order_id Order ID.
- * @param string $status New status, without the wc- prefix.
- */
- do_action( 'woocommerce_order_edit_status', $this->get_id(), $status );
- }
+ $deferred_actions = $this->take_deferred_actions();
+ $this->run_deferred_actions( $deferred_actions, false );
$this->maybe_set_user_billing_email();
parent::save();
- $this->status_transition();
+ if ( null === $this->get_last_save_error() ) {
+ $this->status_transition();
+ }
+
+ $this->run_deferred_actions( $deferred_actions, true );
return $this->get_id();
}
@@ -359,9 +393,24 @@ class WC_Order extends WC_Abstract_Order {
'manual' => (bool) $manual_update,
);
- $this->pending_edit_status = $manual_update && $this->staging_status ? $result['to'] : null;
+ if ( $manual_update && $this->staging_status ) {
+ $this->defer_to_save( 'order_edit_status', 'do_action', array( 'woocommerce_order_edit_status', $this->get_id(), $result['to'] ) );
+ } else {
+ $this->defer_to_save( 'order_edit_status', null );
+ }
if ( $manual_update && ! $this->staging_status ) {
+ /**
+ * Fires for a manual status change of an order, before the change
+ * is written, so a listener that reloads the order sees the old row.
+ * After set_status(), it fires before the paid and completed dates
+ * are set. After stage_status() or update_status(), it fires from
+ * save(), with those dates already set on the order object.
+ *
+ * @since 3.0.0
+ * @param int $order_id Order ID.
+ * @param string $status New status, without the wc- prefix.
+ */
do_action( 'woocommerce_order_edit_status', $this->get_id(), $result['to'] );
}
@@ -431,8 +480,8 @@ class WC_Order extends WC_Abstract_Order {
* update_status() calls this method, then save().
*
* An order class that overrides update_status() with rules of its own must override this method
- * too, in the same class, with the same rules. Abilities stage a status change with this method
- * only when every class that declares update_status() also declares this method.
+ * too, in the same class, with the same rules. A direct call of this method refuses a class that has
+ * not done that. update_status() runs this method through write_through(), so it keeps working.
*
* @since 11.4.0
* @param string $new_status Status to change the order to. No internal wc- prefix is required.
@@ -441,6 +490,8 @@ class WC_Order extends WC_Abstract_Order {
* @return array
*/
public function stage_status( $new_status, $note = '', $manual_update = false ) {
+ $this->refuse_unstaged_override( 'update_status', 'stage_status' );
+
$this->staging_status = true;
try {
return $this->set_status( $new_status, $note, $manual_update );
@@ -464,7 +515,11 @@ class WC_Order extends WC_Abstract_Order {
}
try {
- $this->stage_status( $new_status, $note, $manual );
+ $this->write_through(
+ function () use ( $new_status, $note, $manual ) {
+ return $this->stage_status( $new_status, $note, $manual );
+ }
+ );
$this->save();
} catch ( Exception $e ) {
$logger = wc_get_logger();
@@ -2205,6 +2260,20 @@ class WC_Order extends WC_Abstract_Order {
return $comment_id;
}
+ /**
+ * Queue a note for the order in memory. The next save() adds it with add_order_note() after the write, so
+ * the note sees the saved order and woocommerce_order_note_added fires after the write.
+ *
+ * @since 11.4.0
+ * @param string $note Note to add.
+ * @param int $is_customer_note Is this a note for the customer?.
+ * @param bool $added_by_user Was the note added by a user?.
+ * @param array $meta_data Optional meta data to add to the note. Key value pairs.
+ */
+ public function stage_order_note( $note, $is_customer_note = 0, $added_by_user = false, $meta_data = array() ): void {
+ $this->defer_to_save( uniqid( 'order_note_', true ), array( $this, 'add_order_note' ), array( $note, $is_customer_note, $added_by_user, $meta_data ), true );
+ }
+
/**
* Add an order note for status transition
*
diff --git a/plugins/woocommerce/includes/class-wc-webhook.php b/plugins/woocommerce/includes/class-wc-webhook.php
index 49f34484386..a23add21287 100644
--- a/plugins/woocommerce/includes/class-wc-webhook.php
+++ b/plugins/woocommerce/includes/class-wc-webhook.php
@@ -603,29 +603,37 @@ class WC_Webhook extends WC_Legacy_Webhook {
);
}
- // Track failures.
- // Check for a success, which is a 2xx, 301 or 302 Response Code.
- if ( intval( $response_code ) >= 200 && intval( $response_code ) < 303 ) {
- $this->set_failure_count( 0 );
- // A successful delivery proves the delivery URL works, so it no longer needs a ping.
- $this->set_pending_delivery( false );
-
- if ( 0 !== $this->get_id() ) {
- $this->save();
+ $this->write_through(
+ function () use ( $response_code ) {
+ $this->stage_delivery_result( $response_code );
}
- } else {
- $this->failed_delivery();
+ );
+
+ if ( 0 !== $this->get_id() ) {
+ $this->save();
}
}
/**
- * Track consecutive delivery failures and automatically disable the webhook.
- * if more than 5 consecutive failures occur. A failure is defined as a.
- * non-2xx response.
+ * Track the result of a delivery in memory. The next save() writes it. log_delivery() calls this method,
+ * then save().
*
- * @since 2.2.0
+ * A success, which is a 2xx, 301 or 302 response code, resets the failure count. A failure adds one to
+ * the failure count, and disables the webhook after more than 5 consecutive failures.
+ *
+ * @since 11.4.0
+ * @param int|string $response_code Response code, or the error code of a failed request.
*/
- private function failed_delivery() {
+ public function stage_delivery_result( $response_code ): void {
+ $this->refuse_unstaged_override( 'log_delivery', 'stage_delivery_result' );
+
+ if ( intval( $response_code ) >= 200 && intval( $response_code ) < 303 ) {
+ $this->set_failure_count( 0 );
+ // A successful delivery proves the delivery URL works, so it no longer needs a ping.
+ $this->set_pending_delivery( false );
+ return;
+ }
+
$failures = $this->get_failure_count();
if ( $failures > apply_filters( 'woocommerce_max_webhook_delivery_failures', 5 ) ) {
@@ -635,10 +643,6 @@ class WC_Webhook extends WC_Legacy_Webhook {
} else {
$this->set_failure_count( ++$failures );
}
-
- if ( 0 !== $this->get_id() ) {
- $this->save();
- }
}
/**
@@ -673,9 +677,33 @@ class WC_Webhook extends WC_Legacy_Webhook {
* Send a test ping to the delivery URL, sent when the webhook is first created.
*
* @since 2.2.0
+ * @uses self::stage_delivery_ping()
* @return bool|WP_Error
*/
public function deliver_ping() {
+ $result = $this->write_through(
+ function () {
+ return $this->stage_delivery_ping();
+ }
+ );
+
+ if ( true === $result ) {
+ $this->save();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Send a test ping to the delivery URL and, when it succeeds, clear the pending delivery flag in memory.
+ * The next save() writes it. deliver_ping() calls this method, then save().
+ *
+ * @since 11.4.0
+ * @return bool|WP_Error True when the ping succeeded, an error otherwise.
+ */
+ public function stage_delivery_ping() {
+ $this->refuse_unstaged_override( 'deliver_ping', 'stage_delivery_ping' );
+
$args = array(
'user-agent' => sprintf( 'WooCommerce/%s Hookshot (WordPress/%s)', Constants::get_constant( 'WC_VERSION' ), $GLOBALS['wp_version'] ),
'body' => 'webhook_id=' . $this->get_id(),
@@ -706,7 +734,6 @@ class WC_Webhook extends WC_Legacy_Webhook {
}
$this->set_pending_delivery( false );
- $this->save();
return true;
}
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 0aa5d25acf6..786dd41dd7a 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -12757,7 +12757,7 @@ parameters:
path: includes/class-wc-order.php
-
- message: '#^PHPDoc tag @param has invalid value \(WC_Order \$this Order object\.\)\: Unexpected token "\$this", expected variable at offset 193 on line 5$#'
+ message: '#^PHPDoc tag @param has invalid value \(WC_Order \$this Order object\.\)\: Unexpected token "\$this", expected variable at offset 189 on line 5$#'
identifier: phpDoc.parseError
count: 1
path: includes/class-wc-order.php
@@ -12775,7 +12775,7 @@ parameters:
path: includes/class-wc-order.php
-
- message: '#^PHPDoc tag @param has invalid value \(WC_Order \$this Order object\.\)\: Unexpected token "\$this", expected variable at offset 197 on line 6$#'
+ message: '#^PHPDoc tag @param has invalid value \(WC_Order \$this Order object\.\)\: Unexpected token "\$this", expected variable at offset 187 on line 6$#'
identifier: phpDoc.parseError
count: 1
path: includes/class-wc-order.php
@@ -12825,7 +12825,7 @@ parameters:
-
message: '#^Parameter \#2 \$is_customer_note of method WC_Order\:\:add_order_note\(\) expects int, false given\.$#'
identifier: argument.type
- count: 5
+ count: 4
path: includes/class-wc-order.php
-
@@ -15648,12 +15648,6 @@ parameters:
count: 1
path: includes/class-wc-webhook.php
- -
- message: '#^Method WC_Webhook\:\:failed_delivery\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: includes/class-wc-webhook.php
-
-
message: '#^Method WC_Webhook\:\:get_wp_api_payload\(\) should return array but returns array\|WP_Error\.$#'
identifier: return.type
diff --git a/plugins/woocommerce/phpstan.neon b/plugins/woocommerce/phpstan.neon
index f69c412bfcc..d51c662512d 100644
--- a/plugins/woocommerce/phpstan.neon
+++ b/plugins/woocommerce/phpstan.neon
@@ -16,6 +16,7 @@ parameters:
- includes/react-admin/feature-config.php (?)
bootstrapFiles:
- vendor/autoload.php
+ - tests/Tools/phpstan/StagedWriteRule.php
scanDirectories:
- vendor/wordpress/abilities-api
- packages/action-scheduler
@@ -43,5 +44,55 @@ parameters:
-
message: '#Call to an undefined static method Automattic\\WooCommerce\\StoreApi\\Schemas\\V1\\AbstractSchema::prepare_product_price_response\(\)#'
path: src/StoreApi/Utilities/ProductItemTrait.php
+ # Deprecated code; phpcs excludes this folder too.
+ -
+ identifier: woocommerce.stagedWrite
+ path: includes/legacy/*
+ # stage_order_note() only queues the note. The next save() calls add_order_note(), so an override of that method still runs.
+ -
+ identifier: woocommerce.stagedTwinRefusal
+ message: '#^Method WC_Order::stage_order_note\(\) must start with#'
+ path: includes/class-wc-order.php
+ # The save point that stage_*() methods share: it saves only while the saving method runs the stage method.
+ -
+ identifier: woocommerce.stagedWrite
+ message: '#^Method WC_Abstract_Order::save_if_writing_through\(\) writes outside save\(\)\.#'
+ path: includes/abstracts/abstract-wc-order.php
+ # add_product() saved the item before it added it to the order, and the item ID it returns needs that save. stage_product_item() saves the item only while add_product() runs it; otherwise save_items() writes it.
+ -
+ identifier: woocommerce.stagedWrite
+ message: '#^Method WC_Abstract_Order::stage_product_item\(\) writes outside save\(\)\.#'
+ path: includes/abstracts/abstract-wc-order.php
+ # Payment tokens live in a data store table apart from the order data, and the write takes no order change to stage.
+ -
+ identifier: woocommerce.stagedWrite
+ message: '#^Method WC_Abstract_Order::add_payment_token\(\) writes outside save\(\)\.#'
+ path: includes/abstracts/abstract-wc-order.php
+ # The one place that inserts a note row: save() runs add_order_note() for notes that stage_order_note() queued.
+ -
+ identifier: woocommerce.stagedWrite
+ message: '#^Method WC_Order::add_order_note\(\) writes outside save\(\)\.#'
+ path: includes/class-wc-order.php
+ # Usage counts change through atomic data store updates; a save would rewrite them from stale values.
+ -
+ identifier: woocommerce.stagedWrite
+ message: '#^Method WC_Coupon::(increase|decrease)_usage_count\(\) writes outside save\(\)\.#'
+ path: includes/class-wc-coupon.php
+ # Download counts change through an atomic SQL update; a save writes the whole row and would reopen the race the update avoids.
+ -
+ identifier: woocommerce.stagedWrite
+ message: '#^Method WC_Customer_Download::track_download\(\) writes outside save\(\)\.#'
+ path: includes/class-wc-customer-download.php
+ # A zone object does not hold its shipping methods; add_shipping_method() inserts the row and returns its ID. A twin needs the zone to carry its methods in memory and write them in save() first.
+ -
+ identifier: woocommerce.stagedWrite
+ message: '#^Method WC_Shipping_Zone::add_shipping_method\(\) writes outside save\(\)\.#'
+ path: includes/class-wc-shipping-zone.php
parallel:
maximumNumberOfProcesses: 4
+
+services:
+ -
+ class: Automattic\WooCommerce\Testing\Tools\phpstan\StagedWriteRule
+ tags:
+ - phpstan.rules.rule
diff --git a/plugins/woocommerce/src/Abilities/ActionableAbility.php b/plugins/woocommerce/src/Abilities/ActionableAbility.php
index 25450173e66..10beaecfd72 100644
--- a/plugins/woocommerce/src/Abilities/ActionableAbility.php
+++ b/plugins/woocommerce/src/Abilities/ActionableAbility.php
@@ -208,7 +208,11 @@ abstract class ActionableAbility extends \WP_Ability {
return $updates;
}
- $changed = $this->change( $subject, $input );
+ try {
+ $changed = $this->change( $subject, $input );
+ } catch ( \WC_Data_Exception $e ) {
+ return new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() );
+ }
if ( is_wp_error( $changed ) ) {
return $changed;
}
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
index ac1e248e09c..ea18ca77d72 100644
--- a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -7,6 +7,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Abilities;
+use Automattic\WooCommerce\Internal\Utilities\StagedMethod;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
defined( 'ABSPATH' ) || exit;
@@ -493,6 +494,24 @@ class ExtensibleAbilities {
return array_values( array_unique( $valid ) );
}
+ /**
+ * Refuse a direct call of a stage method when a class overrides its saving
+ * method without overriding the stage method too, because staging would skip
+ * the rules of that override. WC_Data classes use
+ * WC_Data::refuse_unstaged_override(). Another class calls this method at the
+ * start of its stage method, unless its saving method runs the stage method.
+ *
+ * @since 11.4.0
+ *
+ * @param object $subject Object that gets the change.
+ * @param string $method Saving method, for example add_tracking_item.
+ * @param string $stage_method Stage method, for example stage_tracking_item.
+ * @throws \WC_Data_Exception When a class has not adopted the stage method.
+ */
+ public static function refuse_unstaged_override( object $subject, string $method, string $stage_method ): void {
+ StagedMethod::refuse_unstaged( $subject, $method, $stage_method );
+ }
+
/**
* Register a validator that can refuse a change to an object of a resource.
*
@@ -507,9 +526,10 @@ class ExtensibleAbilities {
*
* Core's product abilities and order-update-status run validators. An order
* validator sees the order with its new status staged, including the paid
- * and completed dates that the status sets. While the feature is on,
- * order-update-status refuses an order class that cannot stage a status
- * change. See WC_Order::stage_status().
+ * and completed dates that the status sets. A stage_*() method refuses a class
+ * that overrides its saving method without overriding the stage method too, so
+ * the ability refuses that class while the feature is on. See
+ * WC_Data::refuse_unstaged_override().
*
* @since 11.3.0
*
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
index de34d6a7b85..32853f8e30e 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrderUpdateStatus.php
@@ -11,7 +11,6 @@ use Automattic\WooCommerce\Abilities\ActionableAbility;
use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\DomainAbilityHelpers;
use Automattic\WooCommerce\Internal\Abilities\Domain\Traits\OrderAbilityTrait;
-use Automattic\WooCommerce\Internal\Orders\StagedMethod;
use Automattic\WooCommerce\Utilities\OrderUtil;
defined( 'ABSPATH' ) || exit;
@@ -138,24 +137,7 @@ class OrderUpdateStatus extends ActionableAbility {
return self::update_status( $subject, $status, self::get_note( $input ) );
}
- $unstaged_class = StagedMethod::unstaged_class( $subject, 'update_status', 'stage_status' );
- if ( null !== $unstaged_class ) {
- return new \WP_Error(
- 'woocommerce_order_status_staging_unsupported',
- sprintf(
- /* translators: %s: PHP class name of the order. */
- __( 'This order type does not support this change yet. The class %s overrides update_status() and needs to override stage_status() too.', 'woocommerce' ),
- $unstaged_class
- ),
- array( 'status' => 400 )
- );
- }
-
- try {
- $subject->stage_status( $status, self::get_note( $input ), true );
- } catch ( \WC_Data_Exception $e ) {
- return new \WP_Error( $e->getErrorCode(), $e->getMessage(), $e->getErrorData() );
- }
+ $subject->stage_status( $status, self::get_note( $input ), true );
return null;
}
diff --git a/plugins/woocommerce/src/Internal/Orders/StagedMethod.php b/plugins/woocommerce/src/Internal/Orders/StagedMethod.php
deleted file mode 100644
index b4b1899d2f2..00000000000
--- a/plugins/woocommerce/src/Internal/Orders/StagedMethod.php
+++ /dev/null
@@ -1,55 +0,0 @@
-<?php
-/**
- * StagedMethod class file.
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\Internal\Orders;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * A Core order method that sets and saves in one call, paired with the method that only stages the change in
- * memory. The first method calls the stage method, then save(). For example: update_status() and stage_status().
- *
- * A class that overrides the first method with rules of its own must override the stage method too, at the same
- * level. Otherwise an ability that stages the change skips those rules, so the ability refuses the class.
- *
- * @internal
- * @since 11.4.0
- */
-final class StagedMethod {
-
- /**
- * Name of the first class, from the object's class upward, that declares the method that sets and saves without
- * declaring the stage method. Null when the object's class can stage the change.
- *
- * @param object $subject Object to change, for example an order.
- * @param string $method Method that sets and saves, for example update_status.
- * @param string $stage_method Method that only stages the change, for example stage_status.
- * @return string|null
- */
- public static function unstaged_class( object $subject, string $method, string $stage_method ): ?string {
- $class_name = get_class( $subject );
- while ( $class_name ) {
- if ( self::declares( $class_name, $method ) && ! self::declares( $class_name, $stage_method ) ) {
- return $class_name;
- }
- $class_name = get_parent_class( $class_name );
- }
-
- return null;
- }
-
- /**
- * Whether the class itself declares the method, rather than inherits it.
- *
- * @param string $class_name Class name.
- * @param string $method Method name.
- * @return bool
- */
- private static function declares( string $class_name, string $method ): bool {
- return method_exists( $class_name, $method ) && ( new \ReflectionMethod( $class_name, $method ) )->class === $class_name;
- }
-}
diff --git a/plugins/woocommerce/src/Internal/StockNotifications/Notification.php b/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
index 7d69f2a7cad..8c99f76f7bc 100644
--- a/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
+++ b/plugins/woocommerce/src/Internal/StockNotifications/Notification.php
@@ -534,8 +534,11 @@ class Notification extends \WC_Data {
* @return string The generated verification key.
*/
public function get_verification_key( bool $persist ): string {
- $key = wp_generate_password( 20, false );
- $this->update_meta_data( 'verification_action_key', time() . ':' . wp_fast_hash( $key ) );
+ $key = $this->write_through(
+ function () {
+ return $this->stage_verification_key();
+ }
+ );
if ( $persist ) {
$this->save();
@@ -544,6 +547,21 @@ class Notification extends \WC_Data {
return $key;
}
+ /**
+ * Generate a verification key and set its hash in memory. The next save() writes it.
+ *
+ * @since 11.4.0
+ * @return string The generated verification key.
+ */
+ public function stage_verification_key(): string {
+ $this->refuse_unstaged_override( 'get_verification_key', 'stage_verification_key' );
+
+ $key = wp_generate_password( 20, false );
+ $this->update_meta_data( 'verification_action_key', time() . ':' . wp_fast_hash( $key ) );
+
+ return $key;
+ }
+
/**
* Check if the given key is a valid unsubscribe key.
*
@@ -565,8 +583,11 @@ class Notification extends \WC_Data {
* @return string The generated unsubscribe key.
*/
public function get_unsubscribe_key( bool $persist ): string {
- $key = wp_generate_password( 20, false );
- $this->update_meta_data( 'unsubscribe_action_key', wp_fast_hash( $key ) );
+ $key = $this->write_through(
+ function () {
+ return $this->stage_unsubscribe_key();
+ }
+ );
if ( $persist ) {
$this->save();
@@ -602,4 +623,19 @@ class Notification extends \WC_Data {
return false;
}
+
+ /**
+ * Generate an unsubscribe key and set its hash in memory. The next save() writes it.
+ *
+ * @since 11.4.0
+ * @return string The generated unsubscribe key.
+ */
+ public function stage_unsubscribe_key(): string {
+ $this->refuse_unstaged_override( 'get_unsubscribe_key', 'stage_unsubscribe_key' );
+
+ $key = wp_generate_password( 20, false );
+ $this->update_meta_data( 'unsubscribe_action_key', wp_fast_hash( $key ) );
+
+ return $key;
+ }
}
diff --git a/plugins/woocommerce/src/Internal/Utilities/StagedMethod.php b/plugins/woocommerce/src/Internal/Utilities/StagedMethod.php
new file mode 100644
index 00000000000..09ccf9ecff3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Utilities/StagedMethod.php
@@ -0,0 +1,87 @@
+<?php
+/**
+ * StagedMethod class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\Utilities;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A WC_Data method that sets and saves in one call, paired with the method that only stages the change in memory.
+ * The first method runs the stage method through WC_Data::write_through(), then saves. For example: update_status()
+ * and stage_status().
+ *
+ * A class that overrides the first method with rules of its own must override the stage method too, at the same
+ * level. Otherwise a direct call of the stage method skips those rules, so the stage method refuses the class.
+ * The class keeps working through the first method. A stage method starts with
+ * WC_Data::refuse_unstaged_override(), and PHPStan reports one that does not. An override of a stage method must
+ * call the parent's stage method, because that call runs the refusal.
+ *
+ * @internal
+ * @since 11.4.0
+ */
+final class StagedMethod {
+
+ /**
+ * Name of the first class, from the object's class upward, that declares the method that sets and saves without
+ * declaring the stage method. Null when the object's class can stage the change.
+ *
+ * @param object $subject Object to change, for example an order.
+ * @param string $method Method that sets and saves, for example update_status.
+ * @param string $stage_method Method that only stages the change, for example stage_status.
+ * @return string|null
+ */
+ public static function unstaged_class( object $subject, string $method, string $stage_method ): ?string {
+ $class_name = get_class( $subject );
+ while ( $class_name ) {
+ if ( self::declares( $class_name, $method ) && ! self::declares( $class_name, $stage_method ) ) {
+ return $class_name;
+ }
+ $class_name = get_parent_class( $class_name );
+ }
+
+ return null;
+ }
+
+ /**
+ * Refuse a direct call of the stage method when the object's class overrides the saving method without
+ * overriding the stage method too.
+ *
+ * @param object $subject Object to change, for example an order.
+ * @param string $method Method that sets and saves, for example update_status.
+ * @param string $stage_method Method that only stages the change, for example stage_status.
+ * @throws \WC_Data_Exception When the class has not adopted the stage method.
+ */
+ public static function refuse_unstaged( object $subject, string $method, string $stage_method ): void {
+ $class_name = self::unstaged_class( $subject, $method, $stage_method );
+ if ( null !== $class_name ) {
+ // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- A plain text message for a client, built from PHP names.
+ throw new \WC_Data_Exception(
+ 'woocommerce_data_staging_unsupported',
+ sprintf(
+ /* translators: 1: PHP class name of the object, 2: method that the class overrides, 3: method that the class needs to override. */
+ __( 'This object does not support this change yet. The class %1$s overrides %2$s() and needs to override %3$s() too.', 'woocommerce' ),
+ $class_name,
+ $method,
+ $stage_method
+ ),
+ 400
+ );
+ // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+ }
+ }
+
+ /**
+ * Whether the class itself declares the method, rather than inherits it.
+ *
+ * @param string $class_name Class name.
+ * @param string $method Method name.
+ * @return bool
+ */
+ private static function declares( string $class_name, string $method ): bool {
+ return method_exists( $class_name, $method ) && ( new \ReflectionMethod( $class_name, $method ) )->class === $class_name;
+ }
+}
diff --git a/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRule.php b/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRule.php
new file mode 100644
index 00000000000..97f45aa0d7b
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRule.php
@@ -0,0 +1,208 @@
+<?php
+/**
+ * StagedWriteRule class file.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Testing\Tools\phpstan;
+
+use PhpParser\Node;
+use PhpParser\Node\Expr\Assign;
+use PhpParser\Node\Expr\FuncCall;
+use PhpParser\Node\Expr\MethodCall;
+use PhpParser\Node\Expr\PropertyFetch;
+use PhpParser\Node\Expr\StaticCall;
+use PhpParser\Node\Expr\Variable;
+use PhpParser\Node\Identifier;
+use PhpParser\Node\Name;
+use PhpParser\Node\Stmt\Expression;
+use PhpParser\NodeFinder;
+use PHPStan\Analyser\Scope;
+use PHPStan\Node\InClassMethodNode;
+use PHPStan\Reflection\ClassReflection;
+use PHPStan\Rules\Rule;
+use PHPStan\Rules\RuleErrorBuilder;
+
+/**
+ * Reports a method of a WC_Data subclass that writes to the database outside save(), unless it also calls on $this
+ * the stage_*() method whose refuse_unstaged_override() names it, which marks it as the saving twin of that stage
+ * method. A stage_*() method never gets that exemption. Methods of the save pipeline, deletes, and methods that take a
+ * lock, which must write at once, are exempt. Also reports a stage_*() method of a WC_Data subclass that does not start with
+ * refuse_unstaged_override().
+ *
+ * @implements Rule<InClassMethodNode>
+ */
+final class StagedWriteRule implements Rule {
+
+ private const ALLOWED_METHODS = array( 'save', 'create', 'update', 'read', 'apply_changes', 'save_items', 'save_meta_data', 'validate_props', 'before_data_store_save_or_update', 'after_data_store_save_or_update' );
+
+ private const LOCK_METHODS = array( 'hold_applied_coupons' );
+
+ private const DATA_STORE_WRITE_PREFIXES = array( 'update', 'delete', 'increase', 'decrease', 'set', 'sync', 'create', 'save' );
+
+ private const WRITE_FUNCTIONS = array( 'wp_insert_comment', 'wp_update_post', 'update_post_meta', 'wp_insert_post' );
+
+ private const WPDB_WRITE_METHODS = array( 'query', 'insert', 'update', 'delete', 'replace' );
+
+ /**
+ * Node type this rule checks.
+ *
+ * @return string
+ */
+ public function getNodeType(): string {
+ return InClassMethodNode::class;
+ }
+
+ /**
+ * Report the method when it writes and calls no stage method.
+ *
+ * @param Node $node Method node.
+ * @param Scope $scope Scope of the method.
+ * @return array
+ */
+ public function processNode( Node $node, Scope $scope ): array {
+ $class = $node->getClassReflection();
+ $method = $node->getOriginalNode();
+
+ $name = $method->name->toLowerString();
+ if ( ! $class->isSubclassOf( 'WC_Data' ) || $method->isStatic() || null === $method->stmts || in_array( $name, self::ALLOWED_METHODS, true ) || in_array( $name, self::LOCK_METHODS, true ) || 0 === strpos( $name, 'delete' ) ) {
+ return array();
+ }
+
+ if ( 0 === strpos( $name, 'stage_' ) && ! self::starts_with_refusal( $method->stmts ) ) {
+ return array(
+ RuleErrorBuilder::message( sprintf( 'Method %s::%s() must start with $this->refuse_unstaged_override().', $class->getName(), $method->name->toString() ) )
+ ->identifier( 'woocommerce.stagedTwinRefusal' )
+ ->build(),
+ );
+ }
+
+ $finder = new NodeFinder();
+ $data_stores = array();
+ foreach ( $finder->findInstanceOf( $method->stmts, Assign::class ) as $assign ) {
+ if ( $assign->var instanceof Variable && is_string( $assign->var->name ) && $assign->expr instanceof StaticCall
+ && $assign->expr->class instanceof Name && 'WC_Data_Store' === $assign->expr->class->toString()
+ && $assign->expr->name instanceof Identifier && 'load' === $assign->expr->name->toLowerString() ) {
+ $data_stores[] = $assign->var->name;
+ }
+ }
+
+ $writes = false;
+ $calls_twin = false;
+ foreach ( $finder->find( $method->stmts, fn( Node $call ) => $call instanceof MethodCall || $call instanceof FuncCall ) as $call ) {
+ if ( $call instanceof FuncCall ) {
+ $writes = $writes || ( $call->name instanceof Name && in_array( $call->name->toLowerString(), self::WRITE_FUNCTIONS, true ) );
+ continue;
+ }
+
+ if ( ! $call->name instanceof Identifier ) {
+ continue;
+ }
+
+ $name = $call->name->toLowerString();
+ $calls_twin = $calls_twin || ( self::is_variable( $call->var, 'this' ) && 0 === strpos( $name, 'stage_' ) && self::saving_method_of( $class, $name ) === $method->name->toLowerString() );
+
+ $on_data_store = ( $call->var instanceof PropertyFetch && self::is_variable( $call->var->var, 'this' ) && $call->var->name instanceof Identifier && 'data_store' === $call->var->name->toString() )
+ || ( $call->var instanceof MethodCall && self::is_variable( $call->var->var, 'this' ) && $call->var->name instanceof Identifier && 'get_data_store' === $call->var->name->toLowerString() )
+ || ( $call->var instanceof Variable && in_array( $call->var->name, $data_stores, true ) );
+
+ $writes = $writes
+ || 'save' === $name
+ || ( $on_data_store && self::starts_with_any( $name, self::DATA_STORE_WRITE_PREFIXES ) )
+ || ( self::is_variable( $call->var, 'wpdb' ) && in_array( $name, self::WPDB_WRITE_METHODS, true ) );
+ }
+
+ if ( ! $writes || $calls_twin ) {
+ return array();
+ }
+
+ return array(
+ RuleErrorBuilder::message( sprintf( 'Method %s::%s() writes outside save(). Move the in-memory change to a stage_*() twin, call it here, then save. See WC_Order::stage_status().', $class->getName(), $method->name->toString() ) )
+ ->identifier( 'woocommerce.stagedWrite' )
+ ->build(),
+ );
+ }
+
+ /**
+ * Whether the first statement calls refuse_unstaged_override() on $this.
+ *
+ * @param Node[] $stmts Statements of the method.
+ * @return bool
+ */
+ private static function starts_with_refusal( array $stmts ): bool {
+ $first = $stmts[0] ?? null;
+ if ( ! $first instanceof Expression || ! $first->expr instanceof MethodCall ) {
+ return false;
+ }
+
+ $call = $first->expr;
+ return self::is_variable( $call->var, 'this' ) && $call->name instanceof Identifier && 'refuse_unstaged_override' === $call->name->toString();
+ }
+
+ /**
+ * Name of the saving method that the stage method names in its refuse_unstaged_override() call, in lower case.
+ *
+ * @param ClassReflection $class_reflection Class that calls the stage method.
+ * @param string $stage_method Stage method name in lower case.
+ * @return string|null
+ */
+ private static function saving_method_of( ClassReflection $class_reflection, string $stage_method ): ?string {
+ if ( ! $class_reflection->hasNativeMethod( $stage_method ) ) {
+ return null;
+ }
+
+ $reflection = $class_reflection->getNativeMethod( $stage_method )->getDeclaringClass()->getNativeReflection()->getMethod( $stage_method );
+ $file = $reflection->getFileName();
+ $lines = false === $file ? false : file( $file );
+ if ( false === $lines || false === $reflection->getStartLine() || false === $reflection->getEndLine() ) {
+ return null;
+ }
+
+ $source = implode( '', array_slice( $lines, $reflection->getStartLine() - 1, $reflection->getEndLine() - $reflection->getStartLine() + 1 ) );
+ $tokens = array_values(
+ array_filter(
+ token_get_all( '<?php ' . $source ),
+ fn( $token ) => ! is_array( $token ) || ! in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true )
+ )
+ );
+
+ foreach ( $tokens as $i => $token ) {
+ if ( is_array( $token ) && T_STRING === $token[0] && 'refuse_unstaged_override' === $token[1] && '(' === ( $tokens[ $i + 1 ] ?? null ) ) {
+ $argument = $tokens[ $i + 2 ] ?? null;
+ return is_array( $argument ) && T_CONSTANT_ENCAPSED_STRING === $argument[0] ? strtolower( substr( $argument[1], 1, -1 ) ) : null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Whether the expression is the variable with the given name.
+ *
+ * @param Node $expr Expression.
+ * @param string $name Variable name without the dollar sign.
+ * @return bool
+ */
+ private static function is_variable( Node $expr, string $name ): bool {
+ return $expr instanceof Variable && $name === $expr->name;
+ }
+
+ /**
+ * Whether the name starts with one of the prefixes.
+ *
+ * @param string $name Method name in lower case.
+ * @param string[] $prefixes Prefixes.
+ * @return bool
+ */
+ private static function starts_with_any( string $name, array $prefixes ): bool {
+ foreach ( $prefixes as $prefix ) {
+ if ( 0 === strpos( $name, $prefix ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/discounts/discounts.php b/plugins/woocommerce/tests/legacy/unit-tests/discounts/discounts.php
index b2448d8f8d5..2d1fe8b062e 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/discounts/discounts.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/discounts/discounts.php
@@ -1728,4 +1728,18 @@ class WC_Tests_Discounts extends WC_Unit_Test_Case {
$this->assertEquals( 15.0, $order_item->get_total(), 'Total should be $15 preserving manual discount' );
$this->assertEquals( 15.0, $order->get_total(), 'Order total should be $15 preserving manual discount' );
}
+
+ /**
+ * @testdox Should keep every line of an order whose lines are not saved yet.
+ */
+ public function test_set_items_from_order_keeps_unsaved_lines() {
+ $product = WC_Helper_Product::create_simple_product();
+ $order = wc_create_order();
+ $order->stage_product_item( $product, 1 );
+ $order->stage_product_item( $product, 2 );
+
+ $discounts = new WC_Discounts( $order );
+
+ $this->assertCount( 2, $discounts->get_items() );
+ }
}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
index 31357d989ca..d755715db76 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/order/class-wc-tests-crud-orders.php
@@ -7,6 +7,7 @@
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Enums\ProductTaxStatus;
+use Automattic\WooCommerce\Internal\Utilities\StagedMethod;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\FunctionsMockerHack;
@@ -1176,6 +1177,762 @@ class WC_Tests_CRUD_Orders extends WC_Unit_Test_Case {
$this->assertSame( array( OrderStatus::ON_HOLD ), $statuses );
}
+ /**
+ * Test: stage_payment_complete changes the order in memory, and the next save writes it, adds the note and fires woocommerce_payment_complete.
+ */
+ public function test_stage_payment_complete_changes_the_order_in_memory_only() {
+ $object = new WC_Order();
+ $object->save();
+ $fired = did_action( 'woocommerce_payment_complete' );
+ $notes = count( wc_get_order_notes( array( 'order_id' => $object->get_id() ) ) );
+
+ $this->assertTrue( $object->stage_payment_complete( '12345' ) );
+ $this->assertSame( OrderStatus::COMPLETED, $object->get_status() );
+ $this->assertSame( '12345', $object->get_transaction_id() );
+ $this->assertNotNull( $object->get_date_paid( 'edit' ) );
+
+ $stored = wc_get_order( $object->get_id() );
+ $this->assertSame( OrderStatus::PENDING, $stored->get_status() );
+ $this->assertSame( '', $stored->get_transaction_id() );
+ $this->assertSame( 0, did_action( 'woocommerce_payment_complete' ) - $fired );
+ $this->assertCount( $notes, wc_get_order_notes( array( 'order_id' => $object->get_id() ) ) );
+
+ $object->save();
+ $this->assertSame( OrderStatus::COMPLETED, wc_get_order( $object->get_id() )->get_status() );
+ $this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $fired );
+ $this->assertCount( $notes + 1, wc_get_order_notes( array( 'order_id' => $object->get_id() ) ) );
+
+ $object->save();
+ $this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $fired );
+ }
+
+ /**
+ * Test: stage_payment_complete does not change an order that is not saved yet.
+ */
+ public function test_stage_payment_complete_refuses_an_unsaved_order() {
+ $object = new WC_Order();
+ $fired = did_action( 'woocommerce_payment_complete' );
+
+ $this->assertFalse( $object->stage_payment_complete( '12345' ) );
+ $object->save();
+
+ $this->assertSame( 0, did_action( 'woocommerce_payment_complete' ) - $fired );
+ $this->assertNull( wc_get_order( $object->get_id() )->get_date_paid( 'edit' ) );
+ }
+
+ /**
+ * Test: payment_complete fires woocommerce_payment_complete once, after the write, and does not save an order whose status payment cannot complete.
+ */
+ public function test_payment_complete_fires_its_action_after_the_write() {
+ $statuses = array();
+ $listener = static function ( $order_id, $transaction_id ) use ( &$statuses ) {
+ $statuses[] = array( wc_get_order( $order_id )->get_status(), $transaction_id );
+ };
+ add_action( 'woocommerce_payment_complete', $listener, 10, 2 );
+
+ $object = new WC_Order();
+ $object->save();
+ $this->assertTrue( $object->payment_complete( '12345' ) );
+
+ $updates = did_action( 'woocommerce_update_order' );
+ $skipped = did_action( 'woocommerce_payment_complete_order_status_completed' );
+ $this->assertTrue( $object->payment_complete( '67890' ) );
+
+ remove_action( 'woocommerce_payment_complete', $listener, 10 );
+ $this->assertSame( array( array( OrderStatus::COMPLETED, '12345' ) ), $statuses );
+ $this->assertSame( 1, did_action( 'woocommerce_payment_complete_order_status_completed' ) - $skipped );
+ $this->assertSame( $updates, did_action( 'woocommerce_update_order' ) );
+ $this->assertSame( '12345', wc_get_order( $object->get_id() )->get_transaction_id() );
+ }
+
+ /**
+ * Test: stage_product_item adds an unsaved line item to the order, and the next save writes it.
+ */
+ public function test_stage_product_item_adds_an_unsaved_item() {
+ $product = WC_Helper_Product::create_simple_product();
+ $object = new WC_Order();
+ $object->save();
+
+ $item = $object->stage_product_item( $product, 2 );
+ $this->assertInstanceOf( WC_Order_Item_Product::class, $item );
+ $this->assertSame( 0, $item->get_id() );
+ $this->assertSame( 2, $item->get_quantity() );
+ $this->assertSame( array( $item ), array_values( $object->get_items() ) );
+ $this->assertCount( 0, wc_get_order( $object->get_id() )->get_items() );
+
+ $object->save();
+ $this->assertGreaterThan( 0, $item->get_id() );
+ $this->assertSame( array( $item->get_id() ), array_keys( wc_get_order( $object->get_id() )->get_items() ) );
+ }
+
+ /**
+ * Test: add_product saves the item right away and keys it by its ID, as before.
+ */
+ public function test_add_product_saves_the_item() {
+ $product = WC_Helper_Product::create_simple_product();
+ $object = new WC_Order();
+ $object->save();
+
+ $item_id = $object->add_product( $product, 3 );
+ $this->assertGreaterThan( 0, $item_id );
+ $this->assertSame( array( $item_id ), array_keys( $object->get_items() ) );
+ $this->assertSame( array( $item_id ), array_keys( wc_get_order( $object->get_id() )->get_items() ) );
+ $this->assertSame( 3, $object->get_item( $item_id )->get_quantity() );
+ }
+
+ /**
+ * Test: stage_order_note saves nothing until save, then exactly one note exists.
+ */
+ public function test_stage_order_note_adds_the_note_on_save() {
+ $order = new WC_Order();
+ $order->save();
+ $notes = count( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ) );
+
+ $order->stage_order_note( 'Staged note.' );
+ $this->assertCount( $notes, wc_get_order_notes( array( 'order_id' => $order->get_id() ) ) );
+
+ $order->save();
+ $found = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+ $this->assertCount( $notes + 1, $found );
+ $this->assertSame( 'Staged note.', current( $found )->content );
+
+ $order->save();
+ $this->assertCount( $notes + 1, wc_get_order_notes( array( 'order_id' => $order->get_id() ) ) );
+ }
+
+ /**
+ * Save the order while a listener makes the write fail.
+ *
+ * @param WC_Order $order Order to save.
+ */
+ private function save_with_failed_write( WC_Order $order ): void {
+ $fail = static function () {
+ throw new Exception( 'Disk full.' );
+ };
+ add_action( 'woocommerce_before_order_object_save', $fail );
+ try {
+ $order->save();
+ } finally {
+ remove_action( 'woocommerce_before_order_object_save', $fail );
+ }
+ $this->assertInstanceOf( Exception::class, $order->get_last_save_error() );
+ }
+
+ /**
+ * @testdox A staged coupon is counted once when a save after a failed write succeeds.
+ */
+ public function test_staged_coupon_is_counted_after_a_failed_write_is_retried(): void {
+ $order = WC_Helper_Order::create_order();
+ $this->create_fixed_cart_coupon( 'staged-retry' );
+
+ $order->stage_coupon( 'staged-retry' );
+ $this->save_with_failed_write( $order );
+ $this->assertSame( 0, ( new WC_Coupon( 'staged-retry' ) )->get_usage_count() );
+
+ $order->save();
+ $order->save();
+ $this->assertSame( 1, ( new WC_Coupon( 'staged-retry' ) )->get_usage_count() );
+ }
+
+ /**
+ * @testdox A staged note is added once when a save after a failed write succeeds.
+ */
+ public function test_staged_order_note_is_added_after_a_failed_write_is_retried(): void {
+ $order = new WC_Order();
+ $order->save();
+
+ $order->stage_order_note( 'Staged note.' );
+ $this->save_with_failed_write( $order );
+ $order->save();
+ $order->save();
+
+ $found = array_filter(
+ wc_get_order_notes( array( 'order_id' => $order->get_id() ) ),
+ static function ( $note ) {
+ return 'Staged note.' === $note->content;
+ }
+ );
+ $this->assertCount( 1, $found );
+ }
+
+ /**
+ * @testdox A staged payment fires woocommerce_payment_complete once, when a save after a failed write succeeds.
+ */
+ public function test_staged_payment_complete_fires_after_a_failed_write_is_retried(): void {
+ $order = new WC_Order();
+ $order->save();
+ $fired = did_action( 'woocommerce_payment_complete' );
+
+ $this->assertTrue( $order->stage_payment_complete( '12345' ) );
+ $this->save_with_failed_write( $order );
+ $this->assertSame( 0, did_action( 'woocommerce_payment_complete' ) - $fired );
+
+ $order->save();
+ $order->save();
+ $this->assertSame( 1, did_action( 'woocommerce_payment_complete' ) - $fired );
+ }
+
+ /**
+ * @testdox A status change fires its hooks once, when a save after a failed write succeeds.
+ */
+ public function test_status_transition_fires_after_a_failed_write_is_retried(): void {
+ $order = new WC_Order();
+ $order->save();
+ $changed = did_action( 'woocommerce_order_status_changed' );
+ $transition = did_action( 'woocommerce_order_status_pending_to_processing' );
+
+ $order->set_status( OrderStatus::PROCESSING );
+ $this->save_with_failed_write( $order );
+ $this->assertSame( 0, did_action( 'woocommerce_order_status_changed' ) - $changed );
+ $this->assertSame( 0, did_action( 'woocommerce_order_status_pending_to_processing' ) - $transition );
+
+ $order->save();
+ $order->save();
+ $this->assertSame( 1, did_action( 'woocommerce_order_status_changed' ) - $changed );
+ $this->assertSame( 1, did_action( 'woocommerce_order_status_pending_to_processing' ) - $transition );
+ }
+
+ /**
+ * Test: a staged note is not added when the order write fails.
+ */
+ public function test_stage_order_note_is_skipped_when_the_write_fails() {
+ $order = new WC_Order();
+ $order->save();
+ $notes = count( wc_get_order_notes( array( 'order_id' => $order->get_id() ) ) );
+ $fail = static function () {
+ throw new Exception( 'Disk full.' );
+ };
+
+ $order->stage_order_note( 'Staged note.' );
+ add_action( 'woocommerce_before_order_object_save', $fail );
+ try {
+ $order->save();
+ } finally {
+ remove_action( 'woocommerce_before_order_object_save', $fail );
+ }
+
+ $this->assertInstanceOf( Exception::class, $order->get_last_save_error() );
+ $found = wc_get_order_notes( array( 'order_id' => $order->get_id() ) );
+ $this->assertCount( $notes + 1, $found, 'Only the error note of the failed save is added.' );
+ $this->assertStringNotContainsString( 'Staged note.', current( $found )->content );
+ }
+
+ /**
+ * Test: stage_totals fires woocommerce_order_after_calculate_totals on the next save, right before the write, and a change made by the listener is written.
+ */
+ public function test_staged_after_calculate_totals_fires_on_save() {
+ $order = WC_Helper_Order::create_order();
+ $calls = array();
+ $listener = static function ( $and_taxes, $listened_order, $staged ) use ( &$calls ) {
+ $calls[] = $staged;
+ $listened_order->set_total( 99 );
+ };
+ add_action( 'woocommerce_order_after_calculate_totals', $listener, 10, 3 );
+
+ $order->stage_totals( false );
+ $this->assertSame( array(), $calls );
+
+ $order->save();
+ $this->assertSame( array( true ), $calls );
+ $this->assertEquals( 99, wc_get_order( $order->get_id() )->get_total() );
+
+ $order->calculate_totals( false );
+ remove_action( 'woocommerce_order_after_calculate_totals', $listener, 10 );
+ $this->assertSame( array( true, false ), $calls );
+ }
+
+ /**
+ * Test: a saving method that runs after its stage method replaces the staged entry, so the next save does not
+ * fire woocommerce_order_after_calculate_totals again.
+ */
+ public function test_saving_method_after_its_stage_method_replaces_the_staged_entry() {
+ $order = WC_Helper_Order::create_order();
+ $calls = array();
+ $listener = static function ( $and_taxes, $listened_order, $staged ) use ( &$calls ) {
+ $calls[] = $staged;
+ };
+ add_action( 'woocommerce_order_after_calculate_totals', $listener, 10, 3 );
+
+ $order->stage_totals( false );
+ $order->calculate_totals( false );
+ $order->save();
+ remove_action( 'woocommerce_order_after_calculate_totals', $listener, 10 );
+
+ $this->assertSame( array( false ), $calls );
+ }
+
+ /**
+ * Test: a clone starts without the staged callbacks, so saving the clone and then the original fires
+ * woocommerce_order_after_calculate_totals once, for the original.
+ */
+ public function test_clone_does_not_copy_staged_callbacks() {
+ $order = WC_Helper_Order::create_order();
+ $orders = array();
+ $listener = static function ( $and_taxes, $listened_order ) use ( &$orders ) {
+ $orders[] = $listened_order;
+ };
+ add_action( 'woocommerce_order_after_calculate_totals', $listener, 10, 2 );
+
+ $order->stage_totals( false );
+ $copy = clone $order;
+ $copy->save();
+ $order->save();
+ remove_action( 'woocommerce_order_after_calculate_totals', $listener, 10 );
+
+ $this->assertSame( array( $order ), $orders );
+ }
+
+ /**
+ * Test: stage_coupon() refuses a class that overrides only calculate_totals() before it fires
+ * woocommerce_order_applied_coupon or adds the coupon.
+ */
+ public function test_stage_coupon_refuses_an_unadopted_twin_of_its_chain_before_any_change() {
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'chain' );
+ $coupon->set_discount_type( 'fixed_cart' );
+ $coupon->set_amount( 10 );
+ $coupon->save();
+
+ // phpcs:disable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found
+ $order = new class() extends WC_Order {
+ public function calculate_totals( $and_taxes = true ) {
+ return parent::calculate_totals( $and_taxes );
+ }
+ };
+ // phpcs:enable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found
+ $order->add_product( WC_Helper_Product::create_simple_product(), 1 );
+ $order->calculate_totals();
+
+ $fired = did_action( 'woocommerce_order_applied_coupon' );
+ try {
+ $order->stage_coupon( 'chain' );
+ $this->fail( 'stage_coupon did not refuse the class.' );
+ } catch ( WC_Data_Exception $e ) {
+ $this->assertStringContainsString( 'stage_totals()', $e->getMessage() );
+ }
+
+ $this->assertSame( 0, did_action( 'woocommerce_order_applied_coupon' ) - $fired );
+ $this->assertCount( 0, $order->get_items( 'coupon' ) );
+ }
+
+ /**
+ * Test: a woocommerce_order_after_calculate_totals listener that calls a saving method saves the order from
+ * inside the save of stage_totals(). The action fires once, and the listener's taxes and total are written.
+ */
+ public function test_staged_after_calculate_totals_listener_can_call_a_saving_method() {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate_country' => '',
+ 'tax_rate_state' => '',
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'TAX',
+ 'tax_rate_priority' => '1',
+ 'tax_rate_compound' => '0',
+ 'tax_rate_shipping' => '1',
+ 'tax_rate_order' => '1',
+ 'tax_rate_class' => '',
+ )
+ );
+ $order = WC_Helper_Order::create_order();
+ $calls = 0;
+ $listener = static function ( $and_taxes, $listened_order ) use ( &$calls ) {
+ ++$calls;
+ $listened_order->update_taxes();
+ $listened_order->set_total( 123 );
+ };
+ add_action( 'woocommerce_order_after_calculate_totals', $listener, 10, 2 );
+
+ $order->stage_totals( true );
+ $this->assertSame( 0, $calls );
+
+ $order->save();
+ remove_action( 'woocommerce_order_after_calculate_totals', $listener, 10 );
+ $this->assertSame( 1, $calls );
+
+ $saved = wc_get_order( $order->get_id() );
+ $this->assertEquals( 123, $saved->get_total() );
+ $taxes = $saved->get_taxes();
+ $this->assertCount( 1, $taxes );
+ $this->assertGreaterThan( 0, current( $taxes )->get_id() );
+ $this->assertGreaterThan( 0, (float) $saved->get_cart_tax() );
+ }
+
+ /**
+ * Test: the saving methods save as many times as before, so hooks see saved items, and their stage twins save nothing.
+ */
+ public function test_saving_methods_keep_their_saves() {
+ $order = WC_Helper_Order::create_order();
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'saves' );
+ $coupon->set_discount_type( 'fixed_cart' );
+ $coupon->set_amount( 10 );
+ $coupon->save();
+
+ $count = 0;
+ $count_saves = static function ( $saved ) use ( $order, &$count ) {
+ if ( $saved === $order ) {
+ ++$count;
+ }
+ };
+ add_action( 'woocommerce_after_order_object_save', $count_saves );
+ $saves = static function () use ( &$count ) {
+ return $count;
+ };
+
+ $before = $saves();
+ $order->calculate_totals( true );
+ $this->assertSame( 2, $saves() - $before );
+
+ $before = $saves();
+ $order->calculate_totals( false );
+ $this->assertSame( 1, $saves() - $before );
+
+ $before = $saves();
+ $order->apply_coupon( 'saves' );
+ $this->assertSame( 3, $saves() - $before );
+
+ $item_ids = array();
+ $listener = static function ( $and_taxes, $listened_order ) use ( &$item_ids ) {
+ $item_ids = array_keys( $listened_order->get_items( 'coupon' ) );
+ };
+ add_action( 'woocommerce_order_after_calculate_totals', $listener, 10, 2 );
+ $order->calculate_totals( true );
+ remove_action( 'woocommerce_order_after_calculate_totals', $listener, 10 );
+ $this->assertCount( 1, $item_ids );
+ $this->assertIsInt( $item_ids[0] );
+
+ $before = $saves();
+ $order->stage_totals( true );
+ $order->stage_coupon_removal( 'saves' );
+ remove_action( 'woocommerce_after_order_object_save', $count_saves );
+ $this->assertSame( 0, $saves() - $before );
+ }
+
+ /**
+ * Test: stage_totals recalculates the totals in memory and saves nothing until the next save.
+ */
+ public function test_stage_totals_saves_nothing() {
+ $order = WC_Helper_Order::create_order();
+ $shipping = current( $order->get_items( 'shipping' ) );
+ $shipping->set_total( 20 );
+
+ $this->assertEquals( 60, $order->stage_totals( false ) );
+ $this->assertEquals( 50, wc_get_order( $order->get_id() )->get_total() );
+
+ $order->save();
+ $this->assertEquals( 60, wc_get_order( $order->get_id() )->get_total() );
+ }
+
+ /**
+ * Test: stage_coupon and stage_coupon_removal change the order in memory, and the next save writes the order and fires the removed coupon action.
+ */
+ public function test_stage_coupon_and_removal_write_on_save() {
+ $order = WC_Helper_Order::create_order();
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'staged' );
+ $coupon->set_discount_type( 'fixed_cart' );
+ $coupon->set_amount( 10 );
+ $coupon->save();
+
+ $this->assertTrue( $order->stage_coupon( 'staged' ) );
+ $this->assertEquals( 40, $order->get_total() );
+ $this->assertCount( 0, wc_get_order( $order->get_id() )->get_items( 'coupon' ) );
+ $this->assertEquals( 50, wc_get_order( $order->get_id() )->get_total() );
+
+ $order->save();
+ $this->assertCount( 1, wc_get_order( $order->get_id() )->get_items( 'coupon' ) );
+ $this->assertEquals( 40, wc_get_order( $order->get_id() )->get_total() );
+
+ $removed = did_action( 'woocommerce_order_removed_coupon' );
+ $this->assertTrue( $order->stage_coupon_removal( 'staged' ) );
+ $this->assertEquals( 50, $order->get_total() );
+ $this->assertSame( 0, did_action( 'woocommerce_order_removed_coupon' ) - $removed );
+ $this->assertCount( 1, wc_get_order( $order->get_id() )->get_items( 'coupon' ) );
+
+ $order->save();
+ $this->assertSame( 1, did_action( 'woocommerce_order_removed_coupon' ) - $removed );
+ $this->assertCount( 0, wc_get_order( $order->get_id() )->get_items( 'coupon' ) );
+ $this->assertEquals( 50, wc_get_order( $order->get_id() )->get_total() );
+ }
+
+ /**
+ * Create and save a fixed cart coupon.
+ *
+ * @param string $code Coupon code.
+ * @param int $usage_count Usage count to start with.
+ * @return WC_Coupon
+ */
+ private function create_fixed_cart_coupon( string $code, int $usage_count = 0 ): WC_Coupon {
+ $coupon = new WC_Coupon();
+ $coupon->set_code( $code );
+ $coupon->set_discount_type( 'fixed_cart' );
+ $coupon->set_amount( 1 );
+ $coupon->set_usage_count( $usage_count );
+ $coupon->save();
+
+ return $coupon;
+ }
+
+ /**
+ * @testdox Two staged coupons are counted once each when the order is saved.
+ */
+ public function test_staged_coupons_are_counted_once_each(): void {
+ $order = WC_Helper_Order::create_order();
+ $this->create_fixed_cart_coupon( 'staged-a' );
+ $this->create_fixed_cart_coupon( 'staged-b' );
+
+ $order->stage_coupon( 'staged-a' );
+ $order->stage_coupon( 'staged-b' );
+ $order->save();
+
+ $this->assertSame( 1, ( new WC_Coupon( 'staged-a' ) )->get_usage_count() );
+ $this->assertSame( 1, ( new WC_Coupon( 'staged-b' ) )->get_usage_count() );
+ }
+
+ /**
+ * @testdox A coupon that apply_coupon() adds after a staged coupon is counted once.
+ */
+ public function test_coupon_applied_after_a_staged_coupon_is_counted_once(): void {
+ $order = WC_Helper_Order::create_order();
+ $this->create_fixed_cart_coupon( 'staged-first' );
+ $this->create_fixed_cart_coupon( 'applied-second' );
+
+ $order->stage_coupon( 'staged-first' );
+ $order->apply_coupon( 'applied-second' );
+
+ $this->assertSame( 1, ( new WC_Coupon( 'staged-first' ) )->get_usage_count() );
+ $this->assertSame( 1, ( new WC_Coupon( 'applied-second' ) )->get_usage_count() );
+ }
+
+ /**
+ * @testdox A staged coupon is counted once when payment_complete() saves the order.
+ */
+ public function test_staged_coupon_is_counted_once_on_payment_complete(): void {
+ $order = WC_Helper_Order::create_order();
+ $this->create_fixed_cart_coupon( 'staged-paid' );
+
+ $order->stage_coupon( 'staged-paid' );
+ $order->payment_complete();
+
+ $this->assertSame( 1, ( new WC_Coupon( 'staged-paid' ) )->get_usage_count() );
+ }
+
+ /**
+ * @testdox A staged coupon is counted on an order that already recorded its coupon usage.
+ */
+ public function test_staged_coupon_is_counted_on_a_recorded_order(): void {
+ $order = WC_Helper_Order::create_order();
+ $this->create_fixed_cart_coupon( 'recorded' );
+ $this->create_fixed_cart_coupon( 'staged-late' );
+ $order->apply_coupon( 'recorded' );
+
+ $order->stage_coupon( 'staged-late' );
+ $order->save();
+
+ $this->assertSame( 1, ( new WC_Coupon( 'recorded' ) )->get_usage_count() );
+ $this->assertSame( 1, ( new WC_Coupon( 'staged-late' ) )->get_usage_count() );
+ }
+
+ /**
+ * @testdox A coupon that apply_coupon() adds after the staged removal of the only recorded coupon is counted once.
+ */
+ public function test_coupon_applied_after_staged_removal_of_recorded_coupon_is_counted_once(): void {
+ $order = WC_Helper_Order::create_order();
+ $this->create_fixed_cart_coupon( 'recorded-removed' );
+ $this->create_fixed_cart_coupon( 'applied-replacement' );
+ $order->apply_coupon( 'recorded-removed' );
+
+ $order->stage_coupon_removal( 'recorded-removed' );
+ $order->apply_coupon( 'applied-replacement' );
+
+ $this->assertSame( 0, ( new WC_Coupon( 'recorded-removed' ) )->get_usage_count() );
+ $this->assertSame( 1, ( new WC_Coupon( 'applied-replacement' ) )->get_usage_count() );
+ }
+
+ /**
+ * @testdox A coupon staged after the staged removal of the only recorded coupon is counted once on save.
+ */
+ public function test_coupon_staged_after_staged_removal_of_recorded_coupon_is_counted_once(): void {
+ $order = WC_Helper_Order::create_order();
+ $this->create_fixed_cart_coupon( 'recorded-replaced' );
+ $this->create_fixed_cart_coupon( 'staged-replacement' );
+ $order->apply_coupon( 'recorded-replaced' );
+
+ $order->stage_coupon_removal( 'recorded-replaced' );
+ $order->stage_coupon( 'staged-replacement' );
+ $order->save();
+
+ $this->assertSame( 0, ( new WC_Coupon( 'recorded-replaced' ) )->get_usage_count() );
+ $this->assertSame( 1, ( new WC_Coupon( 'staged-replacement' ) )->get_usage_count() );
+ }
+
+ /**
+ * @testdox Removing a staged coupon before the save leaves no usage of that coupon on the order.
+ */
+ public function test_removing_a_staged_coupon_leaves_no_usage(): void {
+ $order = WC_Helper_Order::create_order();
+ $kept = $this->create_fixed_cart_coupon( 'kept' );
+ $this->create_fixed_cart_coupon( 'staged-removed', 1 );
+ $order->apply_coupon( 'kept' );
+
+ $order->stage_coupon( 'staged-removed' );
+ $order->stage_coupon_removal( 'staged-removed' );
+ $order->save();
+
+ $this->assertSame( 1, ( new WC_Coupon( 'kept' ) )->get_usage_count() );
+ $this->assertSame( 1, ( new WC_Coupon( 'staged-removed' ) )->get_usage_count() );
+ $this->assertSame( array( $kept->get_id() ), array_keys( wc_get_order( $order->get_id() )->get_meta( '_coupon_usage_identities' ) ) );
+ }
+
+ /**
+ * @testdox A staged product item that the order removes before the save is not written.
+ */
+ public function test_staged_product_item_removed_before_save_is_not_written(): void {
+ $order = new WC_Order();
+ $order->save();
+
+ $order->stage_product_item( WC_Helper_Product::create_simple_product() );
+ $order->stage_order_items_removal( 'line_item' );
+ $order->save();
+
+ $this->assertCount( 0, $order->get_items() );
+ $this->assertCount( 0, wc_get_order( $order->get_id() )->get_items() );
+ }
+
+ /**
+ * Build an order of a class that overrides the saving methods without overriding their stage_*() methods.
+ *
+ * @return WC_Order
+ */
+ private function get_order_that_has_not_adopted_stage_methods() {
+ // phpcs:disable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found
+ $object = new class() extends WC_Order {
+ public function update_status( $new_status, $note = '', $manual = false ) {
+ return parent::update_status( $new_status, $note, $manual );
+ }
+ public function payment_complete( $transaction_id = '' ) {
+ return parent::payment_complete( $transaction_id );
+ }
+ public function add_product( $product, $qty = 1, $args = array() ) {
+ return parent::add_product( $product, $qty, $args );
+ }
+ public function calculate_totals( $and_taxes = true ) {
+ return parent::calculate_totals( $and_taxes );
+ }
+ public function apply_coupon( $raw_coupon ) {
+ return parent::apply_coupon( $raw_coupon );
+ }
+ public function remove_coupon( $code ) {
+ return parent::remove_coupon( $code );
+ }
+ public function recalculate_coupons() {
+ parent::recalculate_coupons();
+ }
+ public function calculate_shipping() {
+ return parent::calculate_shipping();
+ }
+ public function calculate_taxes( $args = array() ) {
+ parent::calculate_taxes( $args );
+ }
+ public function update_taxes() {
+ parent::update_taxes();
+ }
+ public function remove_order_items( $type = null ) {
+ parent::remove_order_items( $type );
+ }
+ };
+ // phpcs:enable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found
+ $object->save();
+
+ return $object;
+ }
+
+ /**
+ * Test: a class that overrides the saving methods and has not adopted the stage methods keeps working through the saving methods.
+ */
+ public function test_saving_methods_work_on_a_class_that_has_not_adopted_stage_methods() {
+ $object = $this->get_order_that_has_not_adopted_stage_methods();
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'unadopted' );
+ $coupon->set_discount_type( 'fixed_cart' );
+ $coupon->set_amount( 1 );
+ $coupon->save();
+
+ $item_id = $object->add_product( WC_Helper_Product::create_simple_product(), 2 );
+ $this->assertGreaterThan( 0, $item_id );
+ $this->assertTrue( $object->apply_coupon( 'unadopted' ) );
+ $this->assertCount( 1, $object->get_items( 'coupon' ) );
+ $this->assertTrue( $object->remove_coupon( 'unadopted' ) );
+ $object->recalculate_coupons();
+ $object->calculate_shipping();
+ $object->calculate_taxes();
+ $object->update_taxes();
+ $this->assertEquals( 20, $object->calculate_totals() );
+ $this->assertTrue( $object->update_status( OrderStatus::ON_HOLD, '', true ) );
+ $this->assertTrue( $object->payment_complete( '12345' ) );
+ $object->remove_order_items( 'line_item' );
+ $object->save();
+
+ $stored = wc_get_order( $object->get_id() );
+ $this->assertSame( '12345', $stored->get_transaction_id() );
+ $this->assertCount( 0, $stored->get_items() );
+ }
+
+ /**
+ * Test: a direct call of a stage method refuses a class that overrides its saving method without overriding the stage method.
+ */
+ public function test_stage_methods_refuse_a_class_that_has_not_adopted_them() {
+ $calls = array(
+ 'stage_status' => array( OrderStatus::ON_HOLD, '', true ),
+ 'stage_payment_complete' => array( '12345' ),
+ 'stage_product_item' => array( WC_Helper_Product::create_simple_product() ),
+ 'stage_totals' => array(),
+ 'stage_coupon' => array( 'missing' ),
+ 'stage_coupon_removal' => array( 'missing' ),
+ 'stage_recalculated_coupons' => array(),
+ 'stage_shipping_total' => array(),
+ 'stage_taxes' => array(),
+ 'stage_tax_items' => array(),
+ 'stage_order_items_removal' => array(),
+ );
+
+ foreach ( $calls as $stage_method => $args ) {
+ $object = $this->get_order_that_has_not_adopted_stage_methods();
+ try {
+ $object->$stage_method( ...$args );
+ $this->fail( $stage_method . ' did not refuse the class.' );
+ } catch ( WC_Data_Exception $e ) {
+ $this->assertSame( 'woocommerce_data_staging_unsupported', $e->getErrorCode(), $stage_method );
+ $this->assertStringContainsString( $stage_method . '()', $e->getMessage() );
+ $this->assertSame( OrderStatus::PENDING, $object->get_status(), $stage_method );
+ }
+ }
+ }
+
+ /**
+ * Test: no stage method refuses Core's own order classes, and each stage method runs on a plain order.
+ */
+ public function test_stage_methods_do_not_refuse_core_order_classes() {
+ $pairs = array(
+ 'remove_order_items' => 'stage_order_items_removal',
+ 'apply_coupon' => 'stage_coupon',
+ 'remove_coupon' => 'stage_coupon_removal',
+ 'recalculate_coupons' => 'stage_recalculated_coupons',
+ 'add_product' => 'stage_product_item',
+ 'calculate_shipping' => 'stage_shipping_total',
+ 'calculate_taxes' => 'stage_taxes',
+ 'update_taxes' => 'stage_tax_items',
+ 'calculate_totals' => 'stage_totals',
+ );
+ foreach ( $pairs as $method => $stage_method ) {
+ $this->assertNull( StagedMethod::unstaged_class( new WC_Order(), $method, $stage_method ), $stage_method );
+ $this->assertNull( StagedMethod::unstaged_class( new WC_Order_Refund(), $method, $stage_method ), $stage_method );
+ }
+ $this->assertNull( StagedMethod::unstaged_class( new WC_Order(), 'update_status', 'stage_status' ) );
+ $this->assertNull( StagedMethod::unstaged_class( new WC_Order(), 'payment_complete', 'stage_payment_complete' ) );
+ }
+
/**
* Test: update_status fires woocommerce_order_edit_status before it writes and after the paid and completed dates are set, and set_status fires it before those dates are set.
*/
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
index 9bc340daacc..72e7af8aa47 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
@@ -851,4 +851,92 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
remove_filter( 'woocommerce_logger_log_message', $log_spy, 10 );
}
+ /**
+ * @testdox A class that overrides the saving delivery methods keeps working through them, and the stage methods refuse it.
+ */
+ public function test_stage_delivery_methods_refuse_a_class_that_has_not_adopted_them() {
+ // phpcs:disable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found
+ $webhook = new class() extends WC_Webhook {
+ public function log_delivery( $delivery_id, $request, $response, $duration ) {
+ parent::log_delivery( $delivery_id, $request, $response, $duration );
+ }
+ public function deliver_ping() {
+ return parent::deliver_ping();
+ }
+ };
+ // phpcs:enable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found
+ $webhook->set_status( 'active' );
+ $webhook->set_topic( 'order.created' );
+ $webhook->set_delivery_url( 'https://example.com/webhook' );
+ $webhook->set_user_id( 1 );
+ $webhook->set_failure_count( 0 );
+ $webhook->save();
+
+ $response = array(
+ 'response' => array(
+ 'code' => 500,
+ 'message' => 'Internal Server Error',
+ ),
+ 'headers' => array(),
+ 'body' => '',
+ );
+ $request = array(
+ 'method' => 'POST',
+ 'headers' => array(),
+ 'body' => '',
+ 'user-agent' => 'test',
+ );
+ $webhook->log_delivery( 'delivery', $request, $response, 0.1 );
+ $this->assertSame( 1, wc_get_webhook( $webhook->get_id() )->get_failure_count() );
+
+ $http = static fn() => array(
+ 'response' => array(
+ 'code' => 200,
+ 'message' => 'OK',
+ ),
+ 'headers' => array(),
+ 'body' => '',
+ );
+ add_filter( 'pre_http_request', $http );
+ $this->assertTrue( $webhook->deliver_ping() );
+ remove_filter( 'pre_http_request', $http );
+
+ $calls = array(
+ 'stage_delivery_result' => array( 500 ),
+ 'stage_delivery_ping' => array(),
+ );
+ foreach ( $calls as $stage_method => $args ) {
+ try {
+ $webhook->$stage_method( ...$args );
+ $this->fail( $stage_method . ' did not refuse the class.' );
+ } catch ( WC_Data_Exception $e ) {
+ $this->assertSame( 'woocommerce_data_staging_unsupported', $e->getErrorCode() );
+ $this->assertStringContainsString( $stage_method . '()', $e->getMessage() );
+ }
+ }
+ }
+
+ /**
+ * @testdox stage_delivery_result() counts a failure in memory, and the next save writes it.
+ */
+ public function test_stage_delivery_result_changes_the_webhook_in_memory_only() {
+ $webhook = new WC_Webhook();
+ $webhook->set_status( 'active' );
+ $webhook->set_topic( 'order.created' );
+ $webhook->set_delivery_url( 'https://example.com/webhook' );
+ $webhook->set_user_id( 1 );
+ $webhook->set_failure_count( 0 );
+ $webhook->save();
+
+ $webhook->stage_delivery_result( 500 );
+ $this->assertSame( 1, $webhook->get_failure_count() );
+ $this->assertSame( 0, wc_get_webhook( $webhook->get_id() )->get_failure_count() );
+
+ $webhook->save();
+ $this->assertSame( 1, wc_get_webhook( $webhook->get_id() )->get_failure_count() );
+
+ $webhook->stage_delivery_result( 200 );
+ $webhook->save();
+ $this->assertSame( 0, wc_get_webhook( $webhook->get_id() )->get_failure_count() );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
index 2f51ce0f46f..9df42a17544 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -2994,7 +2994,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$calls = $order_class::$calls;
if ( $enabled ) {
$this->assertSame( 400, $response->get_status() );
- $this->assertSame( 'woocommerce_order_status_staging_unsupported', $response->get_data()['code'] );
+ $this->assertSame( 'woocommerce_data_staging_unsupported', $response->get_data()['code'] );
$this->assertSame( 0, $calls );
$this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
} else {
@@ -4019,7 +4019,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertSame( 'test_on_hold_refused', $refused->get_data()['code'] );
$this->assertSame( 'processing', $updated['order']['status'] );
$this->assertSame( 400, $again->get_status() );
- $this->assertSame( 'woocommerce_order_status_staging_unsupported', $again->get_data()['code'] );
+ $this->assertSame( 'woocommerce_data_staging_unsupported', $again->get_data()['code'] );
$this->assertSame( 'processing', wc_get_order( $order->get_id() )->get_status() );
$this->assertSame( 'pending', wc_get_order( $child->get_id() )->get_status() );
}
@@ -4099,12 +4099,51 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
remove_filter( 'woocommerce_order_class', $filter_class, 100 );
$this->assertSame( 400, $response->get_status() );
- $this->assertSame( 'woocommerce_order_status_staging_unsupported', $response->get_data()['code'] );
+ $this->assertSame( 'woocommerce_data_staging_unsupported', $response->get_data()['code'] );
$this->assertStringContainsString( TestUnstagedOrder::class, $response->get_data()['message'] );
$this->assertStringContainsString( 'stage_status()', $response->get_data()['message'] );
$this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
}
+ /**
+ * @testdox Should refuse an ability that calls a stage method on an order class that overrides the saving method only, and save nothing.
+ */
+ public function test_any_ability_that_stages_refuses_an_unadopted_order_class(): void {
+ $order_class = get_class(
+ new class() extends \WC_Order {
+ /**
+ * Mark the order, then complete the payment.
+ *
+ * @param string $transaction_id Transaction ID.
+ * @return bool
+ */
+ public function payment_complete( $transaction_id = '' ) {
+ $this->update_meta_data( '_test_override', 'yes' );
+ return parent::payment_complete( $transaction_id );
+ }
+ }
+ );
+ $filter_class = static fn() => $order_class;
+ add_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $this->register_order_status_ability( TestOrderStageAbility::class );
+ $this->reset_registries();
+ $order = \WC_Helper_Order::create_order();
+
+ $response = $this->run_ability_response(
+ 'test-ext/order-status',
+ array(
+ 'id' => $order->get_id(),
+ 'status' => 'completed',
+ )
+ );
+
+ remove_filter( 'woocommerce_order_class', $filter_class, 100 );
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'woocommerce_data_staging_unsupported', $response->get_data()['code'] );
+ $this->assertStringContainsString( 'stage_payment_complete()', $response->get_data()['message'] );
+ $this->assertSame( 'pending', wc_get_order( $order->get_id() )->get_status() );
+ }
+
/**
* @testdox Should run a validator on the changed product, after the field callbacks, and save nothing when it refuses.
*/
@@ -4504,6 +4543,36 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
$this->assertArrayNotHasKey( 'additional_data', $data['additional_errors'][0] );
}
+ /**
+ * @testdox Should refuse a class outside WC_Data that overrides a saving method without its stage method.
+ */
+ public function test_refuse_unstaged_override_works_for_a_class_outside_wc_data(): void {
+ $adopted = new class() extends TestStagedService {
+ /**
+ * Save an item.
+ */
+ public function add_item(): void {}
+
+ /**
+ * Stage an item.
+ */
+ public function stage_item(): void {}
+ };
+ $unadopted = new class() extends TestStagedService {
+ /**
+ * Save an item.
+ */
+ public function add_item(): void {}
+ };
+
+ ExtensibleAbilities::refuse_unstaged_override( new TestStagedService(), 'add_item', 'stage_item' );
+ ExtensibleAbilities::refuse_unstaged_override( $adopted, 'add_item', 'stage_item' );
+
+ $this->expectException( \WC_Data_Exception::class );
+ $this->expectExceptionMessage( 'overrides add_item() and needs to override stage_item() too' );
+ ExtensibleAbilities::refuse_unstaged_override( $unadopted, 'add_item', 'stage_item' );
+ }
+
/**
* @testdox Should not run validators with the feature off.
*/
@@ -4682,11 +4751,13 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
/**
* Register test-ext/order-status, an ActionableAbility of an extension that changes orders.
+ *
+ * @param string $ability_class Class of the ability.
*/
- private function register_order_status_ability(): void {
+ private function register_order_status_ability( string $ability_class = TestOrderStatusAbility::class ): void {
$this->add_test_action(
'wp_abilities_api_init',
- static function () {
+ static function () use ( $ability_class ) {
wp_register_ability(
'test-ext/order-status',
array(
@@ -4703,7 +4774,7 @@ class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
),
'output_schema' => array( 'type' => 'object' ),
'permission_callback' => '__return_true',
- 'ability_class' => TestOrderStatusAbility::class,
+ 'ability_class' => $ability_class,
'meta' => array( 'show_in_rest' => true ),
)
);
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestOrderStageAbility.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestOrderStageAbility.php
new file mode 100644
index 00000000000..1173a1a3ddd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestOrderStageAbility.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * TestOrderStageAbility class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+/**
+ * An extension's write ability that stages the payment of an order.
+ */
+class TestOrderStageAbility extends TestOrderStatusAbility {
+
+ /**
+ * Stage the payment.
+ *
+ * @param \WC_Order $subject Order.
+ * @param array $input Ability input.
+ * @return null
+ */
+ public function change( $subject, array $input ) {
+ $subject->stage_payment_complete();
+ return null;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestStagedService.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestStagedService.php
new file mode 100644
index 00000000000..fefbc6c3068
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/TestStagedService.php
@@ -0,0 +1,20 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+/**
+ * A class outside WC_Data with a saving method and its stage method.
+ */
+class TestStagedService {
+
+ /**
+ * Save an item.
+ */
+ public function add_item(): void {}
+
+ /**
+ * Stage an item.
+ */
+ public function stage_item(): void {}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php
index 1eac24435a2..099fcb9fde0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php
+++ b/plugins/woocommerce/tests/php/src/Internal/StockNotifications/NotificationTests.php
@@ -266,6 +266,60 @@ class NotificationTests extends \WC_Unit_Test_Case {
$this->assertFalse( $notification->check_verification_key( 'invalid_key' ) );
}
+ /**
+ * @testdox The stage key methods set the hash in memory and save nothing.
+ */
+ public function test_stage_keys_save_nothing(): void {
+ $notification = new Notification();
+ $notification->set_product_id( 1 );
+ $notification->set_user_email( 'test@example.com' );
+ $notification->save();
+
+ $verification_key = $notification->stage_verification_key();
+ $unsubscribe_key = $notification->stage_unsubscribe_key();
+ $this->assertTrue( $notification->check_verification_key( $verification_key ) );
+ $this->assertTrue( $notification->check_unsubscribe_key( $unsubscribe_key ) );
+
+ $stored = new Notification( $notification->get_id() );
+ $this->assertFalse( $stored->check_verification_key( $verification_key ) );
+ $this->assertFalse( $stored->check_unsubscribe_key( $unsubscribe_key ) );
+ }
+
+ /**
+ * @testdox A class that overrides the key methods keeps working through them, and the stage methods refuse it.
+ */
+ public function test_stage_keys_refuse_a_class_that_has_not_adopted_them(): void {
+ // phpcs:disable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found
+ $notification = new class() extends Notification {
+ public function get_verification_key( bool $persist ): string {
+ return parent::get_verification_key( $persist );
+ }
+ public function get_unsubscribe_key( bool $persist ): string {
+ return parent::get_unsubscribe_key( $persist );
+ }
+ };
+ // phpcs:enable Squiz.Commenting.FunctionComment.Missing, Generic.CodeAnalysis.UselessOverridingMethod.Found
+ $notification->set_product_id( 1 );
+ $notification->set_user_email( 'test@example.com' );
+ $notification->save();
+
+ $verification_key = $notification->get_verification_key( true );
+ $unsubscribe_key = $notification->get_unsubscribe_key( true );
+ $stored = new Notification( $notification->get_id() );
+ $this->assertTrue( $stored->check_verification_key( $verification_key ) );
+ $this->assertTrue( $stored->check_unsubscribe_key( $unsubscribe_key ) );
+
+ foreach ( array( 'stage_verification_key', 'stage_unsubscribe_key' ) as $stage_method ) {
+ try {
+ $notification->$stage_method();
+ $this->fail( $stage_method . ' did not refuse the class.' );
+ } catch ( \WC_Data_Exception $e ) {
+ $this->assertSame( 'woocommerce_data_staging_unsupported', $e->getErrorCode() );
+ $this->assertStringContainsString( $stage_method . '()', $e->getMessage() );
+ }
+ }
+ }
+
/**
* Test verification key persistence.
*/