Skip to content

Commit 18e7698

Browse files
committed
Merge branch 'origin/5.7' into feature/has-purchasables-condition-rule
2 parents 36bca3e + 5ae07f0 commit 18e7698

19 files changed

Lines changed: 883 additions & 40 deletions

CHANGELOG-WIP.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,24 @@
99
- Added `craft\commerce\elements\db\OrderQuery::$containsPurchasables`.
1010
- Added `craft\commerce\elements\db\OrderQuery::containsPurchasables()`.
1111
- Added `craft\commerce\elements\Order::hasPurchasables()`.
12-
13-
## System
14-
12+
- Added `craft\commerce\controllers\OrdersController::actionReassign()`.
13+
- Added `craft\commerce\controllers\OrdersController::actionReassignModal()`.
14+
- Added `craft\commerce\controllers\OrdersController::actionRemoveCustomerData()`.
15+
- Added `craft\commerce\controllers\OrdersController::actionRemoveCustomerDataModal()`.
16+
- Added `craft\commerce\controllers\SubscriptionsController::actionDeleteSubscriptions()`.
17+
- Added `craft\commerce\controllers\SubscriptionsController::actionDeleteSubscriptionsModal()`.
18+
- Added `craft\commerce\elements\Order::getCustomerDeleted()`.
19+
- Added `craft\commerce\elements\Order::setCustomerDeleted()`.
20+
- Added `craft\commerce\elements\deletionblockers\OrderCustomersDeletionBlocker`.
21+
- Added `craft\commerce\elements\deletionblockers\SubscriptionCustomersDeletionBlocker`.
22+
- Added `craft\commerce\services\Orders::reassignOrders()`.
23+
- Added `craft\commerce\services\Orders::removeCustomerData()`.
24+
- `craft\commerce\elements\Subscription::getSubscriber()` now returns `?User` instead of `User`.
25+
26+
### System
27+
- Craft Commerce now requires Craft CMS 5.10.0 or later.
1528
- Craft Commerce now requires `ibericode/vat` 2.0 or later.
29+
- 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.
1630

1731
## Development
1832

src/Plugin.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ public static function editions(): array
265265
/**
266266
* @inheritDoc
267267
*/
268-
public string $schemaVersion = '5.6.1.2';
268+
public string $schemaVersion = '5.7.0.0';
269269

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

787-
Event::on(UserElement::class, UserElement::EVENT_BEFORE_DELETE, [$this->getSubscriptions(), 'beforeDeleteUserHandler']);
788-
Event::on(UserElement::class, UserElement::EVENT_BEFORE_DELETE, [$this->getOrders(), 'beforeDeleteUserHandler']);
789-
787+
Event::on(UserElement::class, UserElement::EVENT_DEFINE_DELETION_BLOCKERS, [$this->getOrders(), 'beforeDeleteUserHandler']);
788+
Event::on(UserElement::class, UserElement::EVENT_DEFINE_DELETION_BLOCKERS, [$this->getSubscriptions(), 'beforeDeleteUserHandler']);
790789
Event::on(Address::class, Address::EVENT_AFTER_SAVE, [$this->getOrders(), 'afterSaveAddressHandler']);
791790

792791
Event::on(

src/controllers/OrdersController.php

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,128 @@ public function actionPaymentAmountData(): Response
13501350
]);
13511351
}
13521352

1353+
/**
1354+
* @since 5.7.0
1355+
*/
1356+
public function actionReassignModal(): Response
1357+
{
1358+
$this->requireCpRequest();
1359+
$this->requireAcceptsJson();
1360+
$this->requirePermission('deleteUsers');
1361+
1362+
$oldUserIds = $this->request->getRequiredParam('oldUserIds');
1363+
1364+
return $this->asCpModal()
1365+
->action('commerce/orders/reassign')
1366+
->contentHtml(fn() =>
1367+
Cp::elementSelectFieldHtml([
1368+
'label' => Craft::t('commerce', 'Choose a new customer'),
1369+
'name' => 'newUserId',
1370+
'elementType' => User::class,
1371+
'criteria' => [
1372+
'id' => array_map(fn($id) => "not $id", $oldUserIds),
1373+
],
1374+
'single' => true,
1375+
]) .
1376+
implode('', array_map(fn($id) => Html::hiddenInput('oldUserIds[]', $id), $oldUserIds))
1377+
)
1378+
->submitButtonLabel(Craft::t('app', 'Reassign'));
1379+
}
1380+
1381+
/**
1382+
* @since 5.7.0
1383+
*/
1384+
public function actionReassign(): Response
1385+
{
1386+
$this->requireCpRequest();
1387+
$this->requireAcceptsJson();
1388+
$this->requirePermission('deleteUsers');
1389+
1390+
$oldUserIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('oldUserIds'));
1391+
$newUserId = (int)$this->request->getRequiredBodyParam('newUserId');
1392+
1393+
if (!$newUserId) {
1394+
return $this->asFailure(Craft::t('commerce', 'No new customer selected.'));
1395+
}
1396+
1397+
try {
1398+
$count = Plugin::getInstance()->getOrders()->reassignOrders($oldUserIds, $newUserId);
1399+
} catch (\Exception) {
1400+
return $this->asFailure(Craft::t('commerce', 'Unable to reassign orders.'));
1401+
}
1402+
1403+
return $this->asSuccess(Craft::t('app', '{type} reassigned.', [
1404+
'type' => $count === 1 ? Order::displayName() : Order::pluralDisplayName(),
1405+
]));
1406+
}
1407+
1408+
/**
1409+
* @return Response
1410+
* @throws BadRequestHttpException
1411+
* @throws ForbiddenHttpException
1412+
* @since 5.7.0
1413+
*/
1414+
public function actionRemoveCustomerDataModal(): Response
1415+
{
1416+
$this->requireCpRequest();
1417+
$this->requireAcceptsJson();
1418+
$this->requirePermission('deleteUsers');
1419+
1420+
$orderIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('orderIds'));
1421+
1422+
return $this->asCpModal()
1423+
->action('commerce/orders/remove-customer-data')
1424+
->contentHtml(fn() =>
1425+
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', [
1426+
'numOrders' => count($orderIds),
1427+
])) .
1428+
Html::beginTag('div') .
1429+
Cp::checkboxSelectFieldHtml([
1430+
'label' => Craft::t('commerce', 'Customer data'),
1431+
'name' => 'customerData',
1432+
'options' => [
1433+
'billingAddressId' => Craft::t('commerce', 'Billing Address'),
1434+
'shippingAddressId' => Craft::t('commerce', 'Shipping Address'),
1435+
'orderCompletedEmail' => Craft::t('commerce', 'Completed Email'),
1436+
],
1437+
'values' => null,
1438+
'showAllOption' => true,
1439+
]) .
1440+
Html::endTag('div') .
1441+
implode('', array_map(fn($id) => Html::hiddenInput('orderIds[]', (string)$id), $orderIds))
1442+
)
1443+
->submitButtonLabel(Craft::t('commerce', 'Remove customer data'));
1444+
}
1445+
1446+
/**
1447+
* @return Response
1448+
* @throws BadRequestHttpException
1449+
* @throws ForbiddenHttpException
1450+
* @since 5.7.0
1451+
*/
1452+
public function actionRemoveCustomerData(): Response
1453+
{
1454+
$this->requireCpRequest();
1455+
$this->requireAcceptsJson();
1456+
$this->requirePermission('deleteUsers');
1457+
1458+
$orderIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('orderIds'));
1459+
$customerData = $this->request->getBodyParam('customerData', []);
1460+
$customerData = $customerData === '' ? [] : $customerData;
1461+
1462+
$customerData = $customerData === '*' ? ['billingAddressId', 'shippingAddressId', 'orderCompletedEmail'] : $customerData;
1463+
1464+
$dataToRemove = array_merge(['customerId', 'email'], $customerData);
1465+
1466+
try {
1467+
Plugin::getInstance()->getOrders()->removeCustomerData($orderIds, $dataToRemove);
1468+
} catch (\Exception) {
1469+
return $this->asFailure(Craft::t('commerce', 'Unable to remove order data.'));
1470+
}
1471+
1472+
return $this->asSuccess(Craft::t('commerce', 'Order customer data removed.'));
1473+
}
1474+
13531475
/**
13541476
* Modifies the variables of the request.
13551477
*/

src/controllers/SubscriptionsController.php

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use craft\commerce\stripe\gateways\PaymentIntents;
2020
use craft\commerce\web\assets\commercecp\CommerceCpAsset;
2121
use craft\helpers\App;
22+
use craft\helpers\Cp;
23+
use craft\helpers\Html;
2224
use craft\helpers\StringHelper;
2325
use craft\helpers\UrlHelper;
2426
use Throwable;
@@ -236,7 +238,7 @@ public function actionSubscribe(): ?Response
236238
} catch (SubscriptionException $exception) {
237239
$error = $exception->getMessage();
238240
}
239-
241+
240242
if ($subscription && $returnUrl) {
241243
$returnUrl = $this->getView()->renderObjectTemplate($returnUrl, $subscription);
242244
$subscriptionRecord = SubscriptionRecord::findOne($subscription->id);
@@ -468,6 +470,160 @@ public function actionCompleteSubscription(): ?Response
468470
}
469471

470472

473+
/**
474+
* @since 5.7.0
475+
*/
476+
public function actionDeleteSubscriptionsModal(): Response
477+
{
478+
$this->requireCpRequest();
479+
$this->requireAcceptsJson();
480+
$this->requirePermission('deleteUsers');
481+
482+
$numSubscriptions = count($this->request->getRequiredParam('subscriptionIds'));
483+
484+
return $this->_renderGatewayCancelModal('commerce/subscriptions/delete-subscriptions')
485+
->submitButtonLabel(Craft::t('app', 'Delete {type}', [
486+
'type' => $numSubscriptions === 1 ? Subscription::lowerDisplayName() : Subscription::pluralLowerDisplayName(),
487+
]));
488+
}
489+
490+
/**
491+
* @since 5.7.0
492+
*/
493+
public function actionDeleteSubscriptions(): Response
494+
{
495+
$this->requireCpRequest();
496+
$this->requireAcceptsJson();
497+
$this->requirePermission('deleteUsers');
498+
499+
$subscriptions = $this->_subscriptionsFromRequest();
500+
$this->_cancelSubscriptionsAtGateway($subscriptions);
501+
502+
foreach ($subscriptions as $subscription) {
503+
if (!Craft::$app->getElements()->deleteElement($subscription)) {
504+
Craft::warning('Failed to delete subscription ' . $subscription->id . ' (' . $subscription->reference . ')', __METHOD__);
505+
}
506+
}
507+
508+
$numSubscriptions = count($subscriptions);
509+
510+
return $this->asSuccess(Craft::t('app', '{type} deleted.', [
511+
'type' => $numSubscriptions === 1 ? Subscription::displayName() : Subscription::pluralDisplayName(),
512+
]));
513+
}
514+
515+
/**
516+
* Returns the gateway cancel modal response, with an action URL for the submit endpoint.
517+
*/
518+
private function _renderGatewayCancelModal(string $actionUrl): \craft\web\Response
519+
{
520+
$subscriptionIds = collect($this->request->getRequiredParam('subscriptionIds'))->filter()->map(fn($id) => (int)$id)->all();
521+
$gatewayId = (int)$this->request->getRequiredParam('gatewayId');
522+
523+
$gateway = Plugin::getInstance()->getGateways()->getGatewayById($gatewayId);
524+
$subscription = Subscription::find()->id($subscriptionIds)->status(null)->one();
525+
526+
$cancelFormHtml = '';
527+
if ($gateway instanceof SubscriptionGateway && $subscription) {
528+
$cancelFormHtml = $gateway->getCancelSubscriptionFormHtml($subscription);
529+
}
530+
531+
return $this->asCpModal()
532+
->action($actionUrl)
533+
->contentHtml(function() use ($cancelFormHtml, $subscriptionIds, $gatewayId) {
534+
$view = Craft::$app->getView();
535+
536+
if ($cancelFormHtml) {
537+
$view->registerJsWithVars(
538+
fn($formId, $inputName) => <<<JS
539+
(function() {
540+
var form = document.getElementById($formId);
541+
var radios = document.querySelectorAll(`input[name=$inputName]`);
542+
function toggle() {
543+
var checked = document.querySelector(`input[name=$inputName]:checked`);
544+
form.style.display = (checked && checked.value === '1') ? '' : 'none';
545+
}
546+
radios.forEach(function(r) { r.addEventListener('change', toggle); });
547+
})();
548+
JS,
549+
[
550+
$view->namespaceInputId('cancel-form'),
551+
$view->namespaceInputName('cancelWithGateway'),
552+
]
553+
);
554+
}
555+
556+
return Cp::fieldHtml('template:_includes/forms/radioGroup.twig', [
557+
'label' => Craft::t('commerce', 'Gateway'),
558+
'name' => 'cancelWithGateway',
559+
'value' => '1',
560+
'options' => [
561+
['label' => Craft::t('commerce', 'Cancel with gateway now'), 'value' => '1'],
562+
['label' => Craft::t('commerce', 'Leave gateway subscription as-is'), 'value' => '0'],
563+
],
564+
]) .
565+
($cancelFormHtml ? Html::tag('div', $cancelFormHtml, ['id' => 'cancel-form']) : '') .
566+
implode('', array_map(fn($id) => Html::hiddenInput('subscriptionIds[]', (string)$id), $subscriptionIds)) .
567+
Html::hiddenInput('gatewayId', (string)$gatewayId);
568+
});
569+
}
570+
571+
/**
572+
* @return Subscription[]
573+
*/
574+
private function _subscriptionsFromRequest(): array
575+
{
576+
$subscriptionIds = collect($this->request->getRequiredParam('subscriptionIds'))->filter()->map(fn($id) => (int)$id)->all();
577+
578+
return Subscription::find()
579+
->id($subscriptionIds)
580+
->status(null)
581+
->all();
582+
}
583+
584+
/**
585+
* Cancels the given subscriptions at the gateway if the request opted in. Returns whether anything was cancelled.
586+
*
587+
* @param Subscription[] $subscriptions
588+
*/
589+
private function _cancelSubscriptionsAtGateway(array $subscriptions): bool
590+
{
591+
$cancelWithGateway = (bool)$this->request->getBodyParam('cancelWithGateway', false);
592+
if (!$cancelWithGateway) {
593+
return false;
594+
}
595+
596+
$gatewayId = (int)$this->request->getRequiredParam('gatewayId');
597+
$gateway = Plugin::getInstance()->getGateways()->getGatewayById($gatewayId);
598+
if (!$gateway instanceof SubscriptionGateway) {
599+
return false;
600+
}
601+
602+
$parameters = $gateway->getCancelSubscriptionFormModel();
603+
foreach ($parameters->attributes() as $attribute) {
604+
$value = $this->request->getBodyParam($attribute);
605+
if ($value !== null) {
606+
$parameters->$attribute = $value;
607+
}
608+
}
609+
610+
$subscriptionsService = Plugin::getInstance()->getSubscriptions();
611+
$cancelled = false;
612+
613+
foreach ($subscriptions as $subscription) {
614+
if (!$subscription->isExpired) {
615+
try {
616+
$subscriptionsService->cancelSubscription($subscription, $parameters);
617+
$cancelled = true;
618+
} catch (Throwable $e) {
619+
Craft::warning('Failed to cancel subscription ' . $subscription->reference . ' with gateway: ' . $e->getMessage(), __METHOD__);
620+
}
621+
}
622+
}
623+
624+
return $cancelled;
625+
}
626+
471627
/**
472628
* @param Subscription $subscription
473629
* @throws ForbiddenHttpException

0 commit comments

Comments
 (0)