Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
# WIP Release Notes for Craft Commerce 5.7

## System
### Extensibility
- Added `craft\commerce\controllers\OrdersController::actionReassign()`.
- Added `craft\commerce\controllers\OrdersController::actionReassignModal()`.
- Added `craft\commerce\controllers\OrdersController::actionRemoveCustomerData()`.
- Added `craft\commerce\controllers\OrdersController::actionRemoveCustomerDataModal()`.
- Added `craft\commerce\controllers\SubscriptionsController::actionDeleteSubscriptions()`.
- Added `craft\commerce\controllers\SubscriptionsController::actionDeleteSubscriptionsModal()`.
- Added `craft\commerce\elements\Order::getCustomerDeleted()`.
- Added `craft\commerce\elements\Order::setCustomerDeleted()`.
- Added `craft\commerce\elements\deletionblockers\OrderCustomersDeletionBlocker`.
- Added `craft\commerce\elements\deletionblockers\SubscriptionCustomersDeletionBlocker`.
- Added `craft\commerce\services\Orders::reassignOrders()`.
- Added `craft\commerce\services\Orders::removeCustomerData()`.
- `craft\commerce\elements\Subscription::getSubscriber()` now returns `?User` instead of `User`.

### System
- Craft Commerce now requires Craft CMS 5.10.0 or later.
- Craft Commerce now requires `ibericode/vat` 2.0 or later.
- When deleting a user with orders or subscriptions, store admins are now presented with actionable options to resolve the blocker (reassign orders, remove customer data, or delete subscriptions), rather than a generic error.

## Development

Expand Down
7 changes: 3 additions & 4 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ public static function editions(): array
/**
* @inheritDoc
*/
public string $schemaVersion = '5.6.1.2';
public string $schemaVersion = '5.7.0.0';

/**
* @inheritdoc
Expand Down Expand Up @@ -784,9 +784,8 @@ private function _registerCraftEventListeners(): void
Event::on(Sites::class, Sites::EVENT_AFTER_SAVE_SITE, [$this->getStores(), 'afterSaveCraftSiteHandler']);
Event::on(Sites::class, Sites::EVENT_AFTER_DELETE_SITE, [$this->getStores(), 'afterDeleteCraftSiteHandler']);

Event::on(UserElement::class, UserElement::EVENT_BEFORE_DELETE, [$this->getSubscriptions(), 'beforeDeleteUserHandler']);
Event::on(UserElement::class, UserElement::EVENT_BEFORE_DELETE, [$this->getOrders(), 'beforeDeleteUserHandler']);

Event::on(UserElement::class, UserElement::EVENT_DEFINE_DELETION_BLOCKERS, [$this->getOrders(), 'beforeDeleteUserHandler']);
Event::on(UserElement::class, UserElement::EVENT_DEFINE_DELETION_BLOCKERS, [$this->getSubscriptions(), 'beforeDeleteUserHandler']);
Event::on(Address::class, Address::EVENT_AFTER_SAVE, [$this->getOrders(), 'afterSaveAddressHandler']);

Event::on(
Expand Down
122 changes: 122 additions & 0 deletions src/controllers/OrdersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -1350,6 +1350,128 @@ public function actionPaymentAmountData(): Response
]);
}

/**
* @since 5.7.0
*/
public function actionReassignModal(): Response
{
$this->requireCpRequest();
$this->requireAcceptsJson();
$this->requirePermission('deleteUsers');

$oldUserIds = $this->request->getRequiredParam('oldUserIds');

return $this->asCpModal()
->action('commerce/orders/reassign')
->contentHtml(fn() =>
Cp::elementSelectFieldHtml([
'label' => Craft::t('commerce', 'Choose a new customer'),
'name' => 'newUserId',
'elementType' => User::class,
'criteria' => [
'id' => array_map(fn($id) => "not $id", $oldUserIds),
],
'single' => true,
]) .
implode('', array_map(fn($id) => Html::hiddenInput('oldUserIds[]', $id), $oldUserIds))
)
->submitButtonLabel(Craft::t('app', 'Reassign'));
}

/**
* @since 5.7.0
*/
public function actionReassign(): Response
{
$this->requireCpRequest();
$this->requireAcceptsJson();
$this->requirePermission('deleteUsers');

$oldUserIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('oldUserIds'));
$newUserId = (int)$this->request->getRequiredBodyParam('newUserId');

if (!$newUserId) {
return $this->asFailure(Craft::t('commerce', 'No new customer selected.'));
}

try {
$count = Plugin::getInstance()->getOrders()->reassignOrders($oldUserIds, $newUserId);
} catch (\Exception) {
return $this->asFailure(Craft::t('commerce', 'Unable to reassign orders.'));
}

return $this->asSuccess(Craft::t('app', '{type} reassigned.', [
'type' => $count === 1 ? Order::displayName() : Order::pluralDisplayName(),
]));
}

/**
* @return Response
* @throws BadRequestHttpException
* @throws ForbiddenHttpException
* @since 5.7.0
*/
public function actionRemoveCustomerDataModal(): Response
{
$this->requireCpRequest();
$this->requireAcceptsJson();
$this->requirePermission('deleteUsers');

$orderIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('orderIds'));

return $this->asCpModal()
->action('commerce/orders/remove-customer-data')
->contentHtml(fn() =>
Html::tag('p', Craft::t('commerce', 'Remove customer association and email from the {numOrders, plural, =1{order} other{orders}}. Optionally select additional customer data to remove below', [
'numOrders' => count($orderIds),
])) .
Html::beginTag('div') .
Cp::checkboxSelectFieldHtml([
'label' => Craft::t('commerce', 'Customer data'),
'name' => 'customerData',
'options' => [
'billingAddressId' => Craft::t('commerce', 'Billing Address'),
'shippingAddressId' => Craft::t('commerce', 'Shipping Address'),
'orderCompletedEmail' => Craft::t('commerce', 'Completed Email'),
],
'values' => null,
'showAllOption' => true,
]) .
Html::endTag('div') .
implode('', array_map(fn($id) => Html::hiddenInput('orderIds[]', (string)$id), $orderIds))
)
->submitButtonLabel(Craft::t('commerce', 'Remove customer data'));
}

/**
* @return Response
* @throws BadRequestHttpException
* @throws ForbiddenHttpException
* @since 5.7.0
*/
public function actionRemoveCustomerData(): Response
{
$this->requireCpRequest();
$this->requireAcceptsJson();
$this->requirePermission('deleteUsers');

$orderIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('orderIds'));
$customerData = $this->request->getBodyParam('customerData', []);
$customerData = $customerData === '' ? [] : $customerData;

$customerData = $customerData === '*' ? ['billingAddressId', 'shippingAddressId', 'orderCompletedEmail'] : $customerData;

$dataToRemove = array_merge(['customerId', 'email'], $customerData);

try {
Plugin::getInstance()->getOrders()->removeCustomerData($orderIds, $dataToRemove);
} catch (\Exception) {
return $this->asFailure(Craft::t('commerce', 'Unable to remove order data.'));
}

return $this->asSuccess(Craft::t('commerce', 'Order customer data removed.'));
}

/**
* Modifies the variables of the request.
*/
Expand Down
158 changes: 157 additions & 1 deletion src/controllers/SubscriptionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use craft\commerce\stripe\gateways\PaymentIntents;
use craft\commerce\web\assets\commercecp\CommerceCpAsset;
use craft\helpers\App;
use craft\helpers\Cp;
use craft\helpers\Html;
use craft\helpers\StringHelper;
use craft\helpers\UrlHelper;
use Throwable;
Expand Down Expand Up @@ -236,7 +238,7 @@ public function actionSubscribe(): ?Response
} catch (SubscriptionException $exception) {
$error = $exception->getMessage();
}

if ($subscription && $returnUrl) {
$returnUrl = $this->getView()->renderObjectTemplate($returnUrl, $subscription);
$subscriptionRecord = SubscriptionRecord::findOne($subscription->id);
Expand Down Expand Up @@ -468,6 +470,160 @@ public function actionCompleteSubscription(): ?Response
}


/**
* @since 5.7.0
*/
public function actionDeleteSubscriptionsModal(): Response
{
$this->requireCpRequest();
$this->requireAcceptsJson();
$this->requirePermission('deleteUsers');

$numSubscriptions = count($this->request->getRequiredParam('subscriptionIds'));

return $this->_renderGatewayCancelModal('commerce/subscriptions/delete-subscriptions')
->submitButtonLabel(Craft::t('app', 'Delete {type}', [
'type' => $numSubscriptions === 1 ? Subscription::lowerDisplayName() : Subscription::pluralLowerDisplayName(),
]));
}

/**
* @since 5.7.0
*/
public function actionDeleteSubscriptions(): Response
{
$this->requireCpRequest();
$this->requireAcceptsJson();
$this->requirePermission('deleteUsers');

$subscriptions = $this->_subscriptionsFromRequest();
$this->_cancelSubscriptionsAtGateway($subscriptions);

foreach ($subscriptions as $subscription) {
if (!Craft::$app->getElements()->deleteElement($subscription)) {
Craft::warning('Failed to delete subscription ' . $subscription->id . ' (' . $subscription->reference . ')', __METHOD__);
}
}

$numSubscriptions = count($subscriptions);

return $this->asSuccess(Craft::t('app', '{type} deleted.', [
'type' => $numSubscriptions === 1 ? Subscription::displayName() : Subscription::pluralDisplayName(),
]));
}

/**
* Returns the gateway cancel modal response, with an action URL for the submit endpoint.
*/
private function _renderGatewayCancelModal(string $actionUrl): \craft\web\Response
{
$subscriptionIds = collect($this->request->getRequiredParam('subscriptionIds'))->filter()->map(fn($id) => (int)$id)->all();
$gatewayId = (int)$this->request->getRequiredParam('gatewayId');

$gateway = Plugin::getInstance()->getGateways()->getGatewayById($gatewayId);
$subscription = Subscription::find()->id($subscriptionIds)->status(null)->one();

$cancelFormHtml = '';
if ($gateway instanceof SubscriptionGateway && $subscription) {
$cancelFormHtml = $gateway->getCancelSubscriptionFormHtml($subscription);
}

return $this->asCpModal()
->action($actionUrl)
->contentHtml(function() use ($cancelFormHtml, $subscriptionIds, $gatewayId) {
$view = Craft::$app->getView();

if ($cancelFormHtml) {
$view->registerJsWithVars(
fn($formId, $inputName) => <<<JS
(function() {
var form = document.getElementById($formId);
var radios = document.querySelectorAll(`input[name=$inputName]`);
function toggle() {
var checked = document.querySelector(`input[name=$inputName]:checked`);
form.style.display = (checked && checked.value === '1') ? '' : 'none';
}
radios.forEach(function(r) { r.addEventListener('change', toggle); });
})();
JS,
[
$view->namespaceInputId('cancel-form'),
$view->namespaceInputName('cancelWithGateway'),
]
);
}

return Cp::fieldHtml('template:_includes/forms/radioGroup.twig', [
'label' => Craft::t('commerce', 'Gateway'),
'name' => 'cancelWithGateway',
'value' => '1',
'options' => [
['label' => Craft::t('commerce', 'Cancel with gateway now'), 'value' => '1'],
['label' => Craft::t('commerce', 'Leave gateway subscription as-is'), 'value' => '0'],
],
]) .
($cancelFormHtml ? Html::tag('div', $cancelFormHtml, ['id' => 'cancel-form']) : '') .
implode('', array_map(fn($id) => Html::hiddenInput('subscriptionIds[]', (string)$id), $subscriptionIds)) .
Html::hiddenInput('gatewayId', (string)$gatewayId);
});
}

/**
* @return Subscription[]
*/
private function _subscriptionsFromRequest(): array
{
$subscriptionIds = collect($this->request->getRequiredParam('subscriptionIds'))->filter()->map(fn($id) => (int)$id)->all();

return Subscription::find()
->id($subscriptionIds)
->status(null)
->all();
}

/**
* Cancels the given subscriptions at the gateway if the request opted in. Returns whether anything was cancelled.
*
* @param Subscription[] $subscriptions
*/
private function _cancelSubscriptionsAtGateway(array $subscriptions): bool
{
$cancelWithGateway = (bool)$this->request->getBodyParam('cancelWithGateway', false);
if (!$cancelWithGateway) {
return false;
}

$gatewayId = (int)$this->request->getRequiredParam('gatewayId');
$gateway = Plugin::getInstance()->getGateways()->getGatewayById($gatewayId);
if (!$gateway instanceof SubscriptionGateway) {
return false;
}

$parameters = $gateway->getCancelSubscriptionFormModel();
foreach ($parameters->attributes() as $attribute) {
$value = $this->request->getBodyParam($attribute);
if ($value !== null) {
$parameters->$attribute = $value;
}
}

$subscriptionsService = Plugin::getInstance()->getSubscriptions();
$cancelled = false;

foreach ($subscriptions as $subscription) {
if (!$subscription->isExpired) {
try {
$subscriptionsService->cancelSubscription($subscription, $parameters);
$cancelled = true;
} catch (Throwable $e) {
Craft::warning('Failed to cancel subscription ' . $subscription->reference . ' with gateway: ' . $e->getMessage(), __METHOD__);
}
}
}

return $cancelled;
}

/**
* @param Subscription $subscription
* @throws ForbiddenHttpException
Expand Down
Loading
Loading