diff --git a/example-templates/src/shop/checkout/payment.twig b/example-templates/src/shop/checkout/payment.twig
index 973991a1ee..f0247626cb 100755
--- a/example-templates/src/shop/checkout/payment.twig
+++ b/example-templates/src/shop/checkout/payment.twig
@@ -115,19 +115,6 @@
{{ cart.gateway.getPaymentConfirmationFormHtml({})|raw }}
{% endif %}
- {% set user = cart.email ? craft.users.email(cart.email).one() : null %}
- {% if not user or not user.getIsCredentialed() %}
-
-
- {{ hiddenInput('registerUserOnOrderComplete', false) }}
- {{ input('checkbox', 'registerUserOnOrderComplete', 1, {
- id: 'registerUserOnOrderComplete'
- }) }}
- {{ 'Create an account'|t }}
-
-
- {% endif %}
-
{{ include('[[folderName]]/checkout/_includes/partial-payment') }}
{% if cart.paymentSourceId or cart.gateway.showPaymentFormSubmitButton() %}
diff --git a/src/Plugin.php b/src/Plugin.php
index b2e0ee0e92..59c2ce8e9c 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -209,7 +209,7 @@ public static function editions(): array
/**
* @inheritDoc
*/
- public string $schemaVersion = '4.2.4';
+ public string $schemaVersion = '4.2.5';
/**
* @inheritdoc
diff --git a/src/console/controllers/ExampleTemplatesController.php b/src/console/controllers/ExampleTemplatesController.php
index 0423e40691..b72242b7b3 100644
--- a/src/console/controllers/ExampleTemplatesController.php
+++ b/src/console/controllers/ExampleTemplatesController.php
@@ -239,6 +239,7 @@ private function _addCssClassesToReplacementData(): void
'[[classes.text.color]]' => "text-$mainColor-500",
'[[classes.text.dangerColor]]' => "text-$dangerColor-500",
'[[classes.a]]' => "text-$mainColor-500 hover:text-$mainColor-600",
+ '[[classes.docs]]' => "text-gray-400 hover:text-gray-600 hover:underline",
'[[classes.input]]' => "border border-gray-300 hover:border-gray-500 px-4 py-2 leading-tight rounded",
'[[classes.box.base]]' => "bg-gray-100 border-$mainColor-300 border-b-2 p-6",
'[[classes.box.selection]]' => "border-$mainColor-300 border-b-2 px-6 py-4 rounded-md shadow-md hover:shadow-lg",
diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php
index aad1586e95..61af99d9bd 100644
--- a/src/controllers/CartController.php
+++ b/src/controllers/CartController.php
@@ -229,12 +229,25 @@ public function actionUpdateCart(): ?Response
}
// Set if the customer should be registered on order completion
- if ($this->request->getBodyParam('registerUserOnOrderComplete')) {
- $this->_cart->registerUserOnOrderComplete = true;
+ $registerUserOnOrderComplete = $this->request->getBodyParam('registerUserOnOrderComplete');
+ if ($registerUserOnOrderComplete !== null) {
+ $this->_cart->registerUserOnOrderComplete = (bool)$registerUserOnOrderComplete;
}
- if ($this->request->getBodyParam('registerUserOnOrderComplete') === 'false') {
- $this->_cart->registerUserOnOrderComplete = false;
+ $saveBillingAddressOnOrderComplete = $this->request->getBodyParam('saveBillingAddressOnOrderComplete');
+ if ($saveBillingAddressOnOrderComplete !== null) {
+ $this->_cart->saveBillingAddressOnOrderComplete = (bool)$saveBillingAddressOnOrderComplete;
+ }
+
+ $saveShippingAddressOnOrderComplete = $this->request->getBodyParam('saveShippingAddressOnOrderComplete');
+ if ($saveShippingAddressOnOrderComplete !== null) {
+ $this->_cart->saveShippingAddressOnOrderComplete = (bool)$saveShippingAddressOnOrderComplete;
+ }
+
+ $saveAddressesOnOrderComplete = $this->request->getBodyParam('saveAddressesOnOrderComplete');
+ if ($saveAddressesOnOrderComplete !== null) {
+ $this->_cart->saveBillingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete;
+ $this->_cart->saveShippingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete;
}
// Set payment currency on cart
diff --git a/src/controllers/PaymentsController.php b/src/controllers/PaymentsController.php
index 9190a163bd..13286648a6 100644
--- a/src/controllers/PaymentsController.php
+++ b/src/controllers/PaymentsController.php
@@ -141,12 +141,25 @@ public function actionPay(): ?Response
}
// Set if the customer should be registered on order completion
- if ($this->request->getBodyParam('registerUserOnOrderComplete')) {
- $order->registerUserOnOrderComplete = true;
+ $registerUserOnOrderComplete = $this->request->getBodyParam('registerUserOnOrderComplete');
+ if ($registerUserOnOrderComplete !== null) {
+ $order->registerUserOnOrderComplete = (bool)$registerUserOnOrderComplete;
}
- if ($this->request->getBodyParam('registerUserOnOrderComplete') === 'false') {
- $order->registerUserOnOrderComplete = false;
+ $saveBillingAddressOnOrderComplete = $this->request->getBodyParam('saveBillingAddressOnOrderComplete');
+ if ($saveBillingAddressOnOrderComplete !== null) {
+ $order->saveBillingAddressOnOrderComplete = (bool)$saveBillingAddressOnOrderComplete;
+ }
+
+ $saveShippingAddressOnOrderComplete = $this->request->getBodyParam('saveShippingAddressOnOrderComplete');
+ if ($saveShippingAddressOnOrderComplete !== null) {
+ $order->saveShippingAddressOnOrderComplete = (bool)$saveShippingAddressOnOrderComplete;
+ }
+
+ $saveAddressesOnOrderComplete = $this->request->getBodyParam('saveAddressesOnOrderComplete');
+ if ($saveAddressesOnOrderComplete !== null) {
+ $order->saveBillingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete;
+ $order->saveShippingAddressOnOrderComplete = (bool)$saveAddressesOnOrderComplete;
}
// These are used to compare if the order changed during its final
diff --git a/src/elements/Order.php b/src/elements/Order.php
index 0717f60694..3357b95f0b 100644
--- a/src/elements/Order.php
+++ b/src/elements/Order.php
@@ -859,6 +859,36 @@ class Order extends Element
*/
public bool $registerUserOnOrderComplete = false;
+ /**
+ * Whether the billing address on the order should be saved to the customer's
+ * address book when the order is complete.
+ *
+ * @var bool Save the order's billing address to the customer's address book
+ * ---
+ * ```php
+ * echo $order->saveBillingAddressOnOrderComplete;
+ * ```
+ * ```twig
+ * {{ order.saveBillingAddressOnOrderComplete }}
+ * ```
+ */
+ public bool $saveBillingAddressOnOrderComplete = false;
+
+ /**
+ * Whether the shipping address on the order should be saved to the customer's
+ * address book when the order is complete.
+ *
+ * @var bool Save the order's shipping address to the customer's address book
+ * ---
+ * ```php
+ * echo $order->saveShippingAddressOnOrderComplete;
+ * ```
+ * ```twig
+ * {{ order.saveShippingAddressOnOrderComplete }}
+ * ```
+ */
+ public bool $saveShippingAddressOnOrderComplete = false;
+
/**
* The current payment source that should be used to make payments on the
* order. If this is set, the `gatewayId` will also be set to the related
@@ -1484,7 +1514,7 @@ protected function defineRules(): array
[['paymentSourceId'], 'validatePaymentSourceId'],
[['email'], 'email'],
- [['number', 'user', 'orderCompletedEmail'], 'safe'],
+ [['number', 'user', 'orderCompletedEmail', 'saveBillingAddressOnOrderComplete', 'saveShippingAddressOnOrderComplete'], 'safe'],
]);
}
@@ -2081,6 +2111,8 @@ public function afterSave(bool $isNew): void
$orderRecord->paymentCurrency = $this->paymentCurrency;
$orderRecord->customerId = $this->getCustomerId();
$orderRecord->registerUserOnOrderComplete = $this->registerUserOnOrderComplete;
+ $orderRecord->saveBillingAddressOnOrderComplete = $this->saveBillingAddressOnOrderComplete;
+ $orderRecord->saveShippingAddressOnOrderComplete = $this->saveShippingAddressOnOrderComplete;
$orderRecord->returnUrl = $this->returnUrl;
$orderRecord->cancelUrl = $this->cancelUrl;
$orderRecord->message = $this->message;
diff --git a/src/elements/db/OrderQuery.php b/src/elements/db/OrderQuery.php
index 17ee7689ec..49eacced74 100644
--- a/src/elements/db/OrderQuery.php
+++ b/src/elements/db/OrderQuery.php
@@ -1485,6 +1485,8 @@ protected function beforePrepare(): bool
'commerce_orders.customerId',
'commerce_orders.dateUpdated',
'commerce_orders.registerUserOnOrderComplete',
+ 'commerce_orders.saveBillingAddressOnOrderComplete',
+ 'commerce_orders.saveShippingAddressOnOrderComplete',
'commerce_orders.recalculationMode',
'commerce_orders.origin',
'commerce_orders.dateAuthorized',
diff --git a/src/migrations/Install.php b/src/migrations/Install.php
index db548dbf69..3edc055cc6 100644
--- a/src/migrations/Install.php
+++ b/src/migrations/Install.php
@@ -373,6 +373,8 @@ public function createTables(): void
'origin' => $this->enum('origin', ['web', 'cp', 'remote'])->notNull()->defaultValue('web'),
'message' => $this->text(),
'registerUserOnOrderComplete' => $this->boolean()->notNull()->defaultValue(false),
+ 'saveBillingAddressOnOrderComplete' => $this->boolean()->notNull()->defaultValue(false),
+ 'saveShippingAddressOnOrderComplete' => $this->boolean()->notNull()->defaultValue(false),
'recalculationMode' => $this->enum('recalculationMode', ['all', 'none', 'adjustmentsOnly'])->notNull()->defaultValue('all'),
'returnUrl' => $this->text(),
'cancelUrl' => $this->text(),
diff --git a/src/migrations/m230705_124845_add_save_address_columns.php b/src/migrations/m230705_124845_add_save_address_columns.php
new file mode 100644
index 0000000000..bcf32d2e04
--- /dev/null
+++ b/src/migrations/m230705_124845_add_save_address_columns.php
@@ -0,0 +1,32 @@
+addColumn(Table::ORDERS, 'saveBillingAddressOnOrderComplete', $this->boolean()->notNull()->defaultValue(false));
+ $this->addColumn(Table::ORDERS, 'saveShippingAddressOnOrderComplete', $this->boolean()->notNull()->defaultValue(false));
+
+ return true;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function safeDown(): bool
+ {
+ echo "m230705_124845_add_save_address_columns cannot be reverted.\n";
+ return false;
+ }
+}
diff --git a/src/records/Order.php b/src/records/Order.php
index 3fc2570ab0..954143636a 100644
--- a/src/records/Order.php
+++ b/src/records/Order.php
@@ -49,6 +49,8 @@
* @property string $paymentCurrency
* @property int $paymentSourceId
* @property bool $registerUserOnOrderComplete
+ * @property bool $saveBillingAddressOnOrderComplete
+ * @property bool $saveShippingAddressOnOrderComplete
* @property string $returnUrl
* @property string $reference
* @property string $recalculationMode
diff --git a/src/services/Customers.php b/src/services/Customers.php
index 9f6259a816..4459d805fa 100644
--- a/src/services/Customers.php
+++ b/src/services/Customers.php
@@ -20,6 +20,8 @@
use craft\db\Query;
use craft\elements\User;
use craft\errors\ElementNotFoundException;
+use craft\errors\InvalidElementException;
+use craft\errors\UnsupportedSiteException;
use craft\helpers\ArrayHelper;
use craft\helpers\Db;
use yii\db\Expression;
@@ -167,6 +169,10 @@ public function orderCompleteHandler(Order $order): void
if ($order->registerUserOnOrderComplete) {
$this->_activateUserFromOrder($order);
}
+
+ if ($order->saveBillingAddressOnOrderComplete || $order->saveShippingAddressOnOrderComplete) {
+ $this->_saveAddressesFromOrder($order);
+ }
}
/**
@@ -307,6 +313,68 @@ public function transferCustomerData(User $fromCustomer, User $toCustomer): bool
return true;
}
+ /**
+ * @param Order $order
+ * @return void
+ * @throws \Throwable
+ * @throws InvalidElementException
+ * @throws UnsupportedSiteException
+ */
+ private function _saveAddressesFromOrder(Order $order): void
+ {
+ // Only for completed orders
+ if ($order->isCompleted === false) {
+ return;
+ }
+
+ // Check for a credentialed user
+ if ($order->getCustomer() === null || !$order->getCustomer()->getIsCredentialed()) {
+ return;
+ }
+
+ $saveBillingAddress = $order->saveBillingAddressOnOrderComplete && $order->sourceBillingAddressId === null && $order->billingAddressId;
+ $saveShippingAddress = $order->saveShippingAddressOnOrderComplete && $order->sourceShippingAddressId === null && $order->shippingAddressId;
+ $newSourceBillingAddressId = null;
+ $newSourceShippingAddressId = null;
+
+ if ($saveBillingAddress && $saveShippingAddress && $order->hasMatchingAddresses()) {
+ // Only save one address if they are matching
+ $newAddress = Craft::$app->getElements()->duplicateElement($order->getBillingAddress(), ['ownerId' => $order->getCustomer()->id]);
+ $newSourceBillingAddressId = $newAddress->id;
+ $newSourceShippingAddressId = $newAddress->id;
+ } else {
+ if ($saveBillingAddress) {
+ $newBillingAddress = Craft::$app->getElements()->duplicateElement($order->getBillingAddress(), ['ownerId' => $order->getCustomer()->id]);
+ $newSourceBillingAddressId = $newBillingAddress->id;
+ }
+
+ if ($saveShippingAddress) {
+ $newShippingAddress = Craft::$app->getElements()->duplicateElement($order->getShippingAddress(), ['ownerId' => $order->getCustomer()->id]);
+ $newSourceShippingAddressId = $newShippingAddress->id;
+ }
+ }
+
+ if ($newSourceBillingAddressId) {
+ $order->sourceBillingAddressId = $newSourceBillingAddressId;
+ }
+
+ if ($newSourceShippingAddressId) {
+ $order->sourceShippingAddressId = $newSourceShippingAddressId;
+ }
+
+ // Manually update the order DB record to avoid looped element saves
+ if ($newSourceBillingAddressId || $newSourceShippingAddressId) {
+ \craft\commerce\records\Order::updateAll([
+ 'sourceBillingAddressId' => $order->sourceBillingAddressId,
+ 'sourceShippingAddressId' => $order->sourceShippingAddressId,
+ ],
+ [
+ 'id' => $order->id,
+ ]
+ );
+ }
+ }
+
/**
* Makes sure the user has an email address and sets them to pending and sends the activation email
*/
diff --git a/tests/unit/controllers/CartTest.php b/tests/unit/controllers/CartTest.php
index a64214df51..e49a61c543 100644
--- a/tests/unit/controllers/CartTest.php
+++ b/tests/unit/controllers/CartTest.php
@@ -406,4 +406,79 @@ public function autoSetNewCartAddressesDataProvider(): array
],
];
}
+
+ /**
+ * @param bool|null $saveBillingAddress
+ * @param bool|null $saveShippingAddress
+ * @param bool|null $saveBoth
+ * @return void
+ * @throws ElementNotFoundException
+ * @throws Exception
+ * @throws InvalidConfigException
+ * @throws InvalidPluginException
+ * @throws InvalidRouteException
+ * @throws Throwable
+ * @since 4.3.0
+ * @dataProvider setSaveAddressesDataProvider
+ */
+ public function testSetSaveAddresses(?bool $saveBillingAddress, ?bool $saveShippingAddress, ?bool $saveBoth): void
+ {
+ Craft::$app->getPlugins()->switchEdition('commerce', Plugin::EDITION_PRO);
+ $this->request->headers->set('X-Http-Method-Override', 'POST');
+
+ $bodyParams = [];
+ if ($saveBoth) {
+ $bodyParams['saveAddressesOnOrderComplete'] = true;
+ } else {
+ $bodyParams['saveBillingAddressOnOrderComplete'] = $saveBillingAddress;
+ $bodyParams['saveShippingAddressOnOrderComplete'] = $saveShippingAddress;
+ }
+
+ $this->request->setBodyParams($bodyParams);
+ $this->cartController->runAction('update-cart');
+
+ $cart = Plugin::getInstance()->getCarts()->getCart();
+
+ if ($saveBoth) {
+ self::assertTrue($cart->saveBillingAddressOnOrderComplete);
+ self::assertTrue($cart->saveShippingAddressOnOrderComplete);
+ } else {
+ self::assertEquals($saveBillingAddress, $cart->saveBillingAddressOnOrderComplete);
+ self::assertEquals($saveShippingAddress, $cart->saveShippingAddressOnOrderComplete);
+ }
+
+ Plugin::getInstance()->getCarts()->forgetCart();
+
+ Craft::$app->getElements()->deleteElement($cart, true);
+ }
+
+ /**
+ * @return array[]
+ * @since 4.3.0
+ */
+ public function setSaveAddressesDataProvider(): array
+ {
+ return [
+ 'save-billing' => [
+ true, // save billing
+ false, // save shipping
+ false, // save both
+ ],
+ 'save-shipping' => [
+ false, // save billing
+ true, // save shipping
+ false, // save both
+ ],
+ 'save-both' => [
+ false, // save billing
+ false, // save shipping
+ true, // save both
+ ],
+ 'save-both-individually' => [
+ true, // save billing
+ true, // save shipping
+ false, // save both
+ ],
+ ];
+ }
}
diff --git a/tests/unit/services/CustomersTest.php b/tests/unit/services/CustomersTest.php
index f34f44a90c..d938d69072 100644
--- a/tests/unit/services/CustomersTest.php
+++ b/tests/unit/services/CustomersTest.php
@@ -229,6 +229,9 @@ public function testRegisterOnCheckoutCopyAddresses(string $email, ?array $billi
$this->_deleteElementIds[] = $order->getCustomer()->id;
}
+ /**
+ * @return array[]
+ */
public function registerOnCheckoutCopyAddressesDataProvider(): array
{
$billingAddress = [
@@ -276,6 +279,201 @@ public function registerOnCheckoutCopyAddressesDataProvider(): array
];
}
+ /**
+ * @param bool|null $saveBilling
+ * @param array|null $billingAddress
+ * @param bool|null $saveShipping
+ * @param array|null $shippingAddress
+ * @param bool $setSourceBilling
+ * @param bool $setSourceShipping
+ * @return void
+ * @throws ElementNotFoundException
+ * @throws Exception
+ * @throws OrderStatusException
+ * @throws \Throwable
+ * @dataProvider saveAddressesOnOrderCompleteDataProvider
+ * @since 4.3.0
+ */
+ public function testSaveAddressesOnOrderComplete(?bool $saveBilling, ?array $billingAddress, ?bool $saveShipping, ?array $shippingAddress, int $newAddressCount, bool $setSourceBilling, bool $setSourceShipping): void
+ {
+ $order = $this->_createOrder('cred.user@crafttest.com');
+ $customer = $order->getCustomer();
+ $sourceAddress = [
+ 'fullName' => 'Source Address',
+ 'addressLine1' => '1 Source Road',
+ 'locality' => 'Sourcington',
+ 'administrativeArea' => 'OR',
+ 'postalCode' => '991199',
+ 'countryCode' => 'US',
+ 'ownerId' => $customer->id,
+ ];
+
+ if ($setSourceBilling || $setSourceShipping) {
+ $sourceAddressModel = \Craft::createObject([
+ 'class' => Address::class,
+ 'attributes' => $sourceAddress,
+ ]);
+ \Craft::$app->getElements()->saveElement($sourceAddressModel, false, false ,false);
+ $this->_deleteElementIds[] = $sourceAddressModel->id;
+
+ if ($setSourceBilling) {
+ $order->sourceBillingAddressId = $sourceAddressModel->id;
+ }
+
+ if ($setSourceShipping) {
+ $order->sourceShippingAddressId = $sourceAddressModel->id;
+ }
+ }
+ $originalAddressIds = collect($customer->getAddresses())->pluck('id')->all();
+
+ $order->saveBillingAddressOnOrderComplete = $saveBilling;
+ $order->saveShippingAddressOnOrderComplete = $saveShipping;
+
+ $order->setBillingAddress($billingAddress);
+ $order->setShippingAddress($shippingAddress);
+
+ \Craft::$app->getElements()->saveElement($order, false, false, false);
+
+ self::assertTrue($order->markAsComplete());
+
+ // @TODO change this to `$customer->getAddresses()` when `getAddresses()` memoization is fixed
+ $idWhere = array_merge(['not'], $originalAddressIds);
+ $addresses = Address::find()->ownerId($customer->id)->id($idWhere)->all();
+ self::assertCount($newAddressCount, $addresses);
+ $addressNames = collect($addresses)->pluck('fullName')->all();
+ $addressLine1s = collect($addresses)->pluck('addressLine1')->all();
+
+ if ($billingAddress && $saveBilling && !$setSourceBilling) {
+ self::assertContains($billingAddress['fullName'], $addressNames);
+ self::assertContains($billingAddress['addressLine1'], $addressLine1s);
+ }
+
+ if ($shippingAddress && $saveShipping && !$setSourceShipping) {
+ self::assertContains($shippingAddress['fullName'], $addressNames);
+ self::assertContains($shippingAddress['addressLine1'], $addressLine1s);
+ }
+
+ $this->_deleteElementIds[] = $order->id;
+// \Craft::$app->getElements()->deleteElementById($order->id, null, null, true);
+ // No need to delete the customer as it comes from the fixtures
+ }
+
+ /**
+ * @return array
+ */
+ public function saveAddressesOnOrderCompleteDataProvider(): array
+ {
+ $billingAddress = [
+ 'fullName' => 'Billing Name',
+ 'addressLine1' => '1 Main Billing Street',
+ 'locality' => 'Billingsville',
+ 'administrativeArea' => 'OR',
+ 'postalCode' => '12345',
+ 'countryCode' => 'US',
+ ];
+ $shippingAddress = [
+ 'fullName' => 'Shipping Name',
+ 'addressLine1' => '1 Main Shipping Street',
+ 'locality' => 'Shippingsville',
+ 'administrativeArea' => 'AL',
+ 'postalCode' => '98765',
+ 'countryCode' => 'US',
+ ];
+
+ return [
+ 'save-both' => [
+ true, // save billing
+ $billingAddress, // billing address
+ true, // save shipping
+ $shippingAddress, // shipping address
+ 2, // new address count
+ false, // set source billing
+ false, // set source shipping
+ ],
+ 'save-billing-only' => [
+ true,
+ $billingAddress,
+ false,
+ null,
+ 1,
+ false,
+ false,
+ ],
+ 'save-shipping-only' => [
+ false,
+ null,
+ true,
+ $shippingAddress,
+ 1,
+ false,
+ false,
+ ],
+ 'save-both-but-same-address' => [
+ true,
+ $billingAddress,
+ true,
+ $billingAddress,
+ 1,
+ false,
+ false,
+ ],
+ 'try-to-save-both-but-no-addresses' => [
+ true,
+ null,
+ true,
+ null,
+ 0,
+ false,
+ false,
+ ],
+ 'try-to-save-but-source-billing-present' => [
+ true,
+ $billingAddress,
+ false,
+ null,
+ 0,
+ true,
+ false,
+ ],
+ 'try-to-save-but-source-shipping-present' => [
+ false,
+ null,
+ true,
+ $shippingAddress,
+ 0,
+ false,
+ true,
+ ],
+ 'try-to-save-both-but-sources-present' => [
+ true,
+ $billingAddress,
+ true,
+ $shippingAddress,
+ 0,
+ true,
+ true,
+ ],
+ 'try-save-both-but-billing-source-present' => [
+ true,
+ $billingAddress,
+ true,
+ $shippingAddress,
+ 1,
+ true,
+ false,
+ ],
+ 'try-save-both-but-shipping-source-present' => [
+ true,
+ $billingAddress,
+ true,
+ $shippingAddress,
+ 1,
+ false,
+ true,
+ ],
+ ];
+ }
+
/**
* @inheritdoc
*/