diff --git a/src/install/sql/content.sql b/src/install/sql/content.sql index f71f28ece..b48901e6e 100644 --- a/src/install/sql/content.sql +++ b/src/install/sql/content.sql @@ -304,7 +304,7 @@ LOCK TABLES `setting` WRITE; INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`) VALUES - (1,'last_patch','94',0,NULL,NULL,NOW(),NOW()), + (1,'last_patch','95',0,NULL,NULL,NOW(),NOW()), (2,'company_name','Company Name',0,NULL,NULL,NOW(),NOW()), (3,'company_email','support@yourcompany.com',0,NULL,NULL,NOW(),NOW()), (4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,NOW(),NOW()), diff --git a/src/install/sql/structure.sql b/src/install/sql/structure.sql index 156f90a03..b4b9a4791 100644 --- a/src/install/sql/structure.sql +++ b/src/install/sql/structure.sql @@ -348,6 +348,7 @@ CREATE TABLE `client_order` ( `reason` varchar(255) DEFAULT NULL COMMENT 'suspend/cancel reason', `notes` text, `config` text, + `suspension_grace_days` int(11) DEFAULT NULL, `referred_by` varchar(255) DEFAULT NULL, `expires_at` datetime DEFAULT NULL, `activated_at` datetime DEFAULT NULL, @@ -360,7 +361,8 @@ CREATE TABLE `client_order` ( KEY `client_id_idx` (`client_id`), KEY `product_id_idx` (`product_id`), KEY `form_id_idx` (`form_id`), - KEY `promo_id_idx` (`promo_id`) + KEY `promo_id_idx` (`promo_id`), + KEY `client_order_status_expires_at_idx` (`status`, `expires_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; /*!40101 SET character_set_client = @saved_cs_client */; @@ -816,6 +818,7 @@ CREATE TABLE `product` ( `allow_quantity_select` tinyint(1) DEFAULT '0', `stock_control` tinyint(1) DEFAULT '0', `quantity_in_stock` int(11) DEFAULT '0', + `suspension_grace_days` int(11) NOT NULL DEFAULT '0', `plugin` varchar(255) DEFAULT NULL, `plugin_config` text, `upgrades` text, diff --git a/src/library/FOSSBilling/UpdatePatcher.php b/src/library/FOSSBilling/UpdatePatcher.php index f5f8f5167..2be558d72 100644 --- a/src/library/FOSSBilling/UpdatePatcher.php +++ b/src/library/FOSSBilling/UpdatePatcher.php @@ -504,6 +504,7 @@ class UpdatePatcher implements InjectionAwareInterface 92 => 'patch92', 93 => 'patch93', 94 => 'patch94', + 95 => 'patch95', ]; ksort($patches, SORT_NATURAL); @@ -2580,6 +2581,21 @@ class UpdatePatcher implements InjectionAwareInterface } } + private function patch95(): void + { + if (!$this->tableHasColumn('product', 'suspension_grace_days')) { + $this->executeSql("ALTER TABLE `product` ADD COLUMN `suspension_grace_days` int(11) NOT NULL DEFAULT '0' AFTER `quantity_in_stock`"); + } + + if (!$this->tableHasColumn('client_order', 'suspension_grace_days')) { + $this->executeSql('ALTER TABLE `client_order` ADD COLUMN `suspension_grace_days` int(11) DEFAULT NULL AFTER `config`'); + } + + if (!$this->tableHasIndex('client_order', 'client_order_status_expires_at_idx')) { + $this->executeSql('ALTER TABLE `client_order` ADD INDEX `client_order_status_expires_at_idx` (`status`, `expires_at`)'); + } + } + private function generateDownloadableStoredFilename(): string { do { diff --git a/src/library/FOSSBilling/Validation/NonNegativeIntegerValidator.php b/src/library/FOSSBilling/Validation/NonNegativeIntegerValidator.php new file mode 100644 index 000000000..63aabc2df --- /dev/null +++ b/src/library/FOSSBilling/Validation/NonNegativeIntegerValidator.php @@ -0,0 +1,33 @@ += 0) { + return $value; + } + + if (is_string($value) && preg_match('/^(0|[1-9]\d*)$/D', $value) === 1) { + $normalized = (int) $value; + if ((string) $normalized === $value) { + return $normalized; + } + } + + throw new InformationException($message); + } +} diff --git a/src/library/Server/Manager/Directadmin.php b/src/library/Server/Manager/Directadmin.php index e2bbe2651..6e5ec060f 100644 --- a/src/library/Server/Manager/Directadmin.php +++ b/src/library/Server/Manager/Directadmin.php @@ -458,6 +458,11 @@ class Server_Manager_Directadmin extends Server_Manager 'suspend' => 'Suspend', 'select0' => $account->getUsername(), ]; + $note = $account->getNote(); + if ($note !== null && $note !== '') { + $fields['reason'] = strcasecmp($note, 'Non-payment') === 0 ? 'billing' : 'other'; + $fields['details'] = $note; + } $this->request('API_SELECT_USERS', $fields); diff --git a/src/modules/Cron/Service.php b/src/modules/Cron/Service.php index 29a923122..f6097171f 100644 --- a/src/modules/Cron/Service.php +++ b/src/modules/Cron/Service.php @@ -82,6 +82,7 @@ class Service 'invoice_batch_generate', 'invoice_batch_send_reminders', 'invoice_batch_invoke_due_event', + 'order_batch_send_suspension_warnings', 'order_batch_suspend_expired', 'order_batch_cancel_suspended', 'support_batch_ticket_auto_close', diff --git a/src/modules/Cron/tests/Unit/ServiceTest.php b/src/modules/Cron/tests/Unit/ServiceTest.php index 335abceb6..984b047ad 100644 --- a/src/modules/Cron/tests/Unit/ServiceTest.php +++ b/src/modules/Cron/tests/Unit/ServiceTest.php @@ -136,6 +136,7 @@ test('runCrons isolates failures in core batch tasks', function (string $failedT 'invoice generation' => 'invoice_batch_generate', 'invoice reminders' => 'invoice_batch_send_reminders', 'invoice due events' => 'invoice_batch_invoke_due_event', + 'order suspension warning' => 'order_batch_send_suspension_warnings', 'order suspension' => 'order_batch_suspend_expired', 'order cancellation' => 'order_batch_cancel_suspended', 'support ticket auto-close' => 'support_batch_ticket_auto_close', diff --git a/src/modules/Order/Api/Admin.php b/src/modules/Order/Api/Admin.php index 8012fab0b..05a760d93 100644 --- a/src/modules/Order/Api/Admin.php +++ b/src/modules/Order/Api/Admin.php @@ -137,6 +137,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi * @optional string $reason - order status change reason * @optional string $notes - order notes * @optional array $meta - list of meta properties + * @optional int $suspension_grace_days - per-order grace period override; empty inherits the product setting * * @return bool */ @@ -312,6 +313,18 @@ class Admin extends \FOSSBilling\Api\AbstractApi return $this->getService()->batchSuspendExpired(); } + /** + * Send final warnings for orders due to be suspended within 24 hours. + * + * @return bool + */ + public function batch_send_suspension_warnings($data) + { + $this->checkPermissions('order', 'manage'); + + return $this->getService()->batchSendSuspensionWarnings(); + } + /** * Cancel all suspended orders. * Configure how many days suspended order should be kept before canceling. diff --git a/src/modules/Order/Entity/Order.php b/src/modules/Order/Entity/Order.php index bef638e48..51ab72414 100644 --- a/src/modules/Order/Entity/Order.php +++ b/src/modules/Order/Entity/Order.php @@ -13,6 +13,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Index(name: 'product_id_idx', columns: ['product_id'])] #[ORM\Index(name: 'form_id_idx', columns: ['form_id'])] #[ORM\Index(name: 'promo_id_idx', columns: ['promo_id'])] +#[ORM\Index(name: 'client_order_status_expires_at_idx', columns: ['status', 'expires_at'])] #[ORM\HasLifecycleCallbacks] class Order { @@ -118,6 +119,9 @@ class Order #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $config = null; + #[ORM\Column(name: 'suspension_grace_days', type: Types::INTEGER, nullable: true)] + private ?int $suspensionGraceDays = null; + #[ORM\Column(name: 'referred_by', type: Types::STRING, length: 255, nullable: true)] private ?string $referredBy = null; @@ -382,6 +386,16 @@ class Order $this->config = $config; } + public function getSuspensionGraceDays(): ?int + { + return $this->suspensionGraceDays; + } + + public function setSuspensionGraceDays(?int $suspensionGraceDays): void + { + $this->suspensionGraceDays = $suspensionGraceDays; + } + public function getReferredBy(): ?string { return $this->referredBy; diff --git a/src/modules/Order/Repository/OrderRepository.php b/src/modules/Order/Repository/OrderRepository.php index b3fbb101c..32e22bfe8 100644 --- a/src/modules/Order/Repository/OrderRepository.php +++ b/src/modules/Order/Repository/OrderRepository.php @@ -53,14 +53,58 @@ class OrderRepository extends EntityRepository */ public function getExpired(): array { - return $this->createQueryBuilder('o') - ->where('o.status = :status') - ->andWhere('o.expiresAt IS NOT NULL') - ->andWhere('o.expiresAt <= :now') - ->setParameter('status', Order::STATUS_ACTIVE) - ->setParameter('now', new \DateTime()) - ->getQuery() - ->getResult(); + $ids = $this->getEntityManager()->getConnection()->fetchFirstColumn( + <<<'SQL' + SELECT o.id + FROM client_order o + LEFT JOIN product p ON p.id = o.product_id + WHERE o.status = :status + AND o.expires_at IS NOT NULL + AND DATE_ADD( + o.expires_at, + INTERVAL GREATEST(COALESCE(o.suspension_grace_days, p.suspension_grace_days, 0), 0) DAY + ) <= NOW() + ORDER BY o.id + SQL, + ['status' => Order::STATUS_ACTIVE] + ); + + return $ids === [] ? [] : $this->findBy(['id' => array_map('intval', $ids)]); + } + + /** + * @return array + */ + public function getDueSuspensionWarnings(): array + { + $rows = $this->getEntityManager()->getConnection()->fetchAllAssociative( + <<<'SQL' + SELECT due.id, due.suspension_at + FROM ( + SELECT + o.id, + GREATEST(COALESCE(o.suspension_grace_days, p.suspension_grace_days, 0), 0) AS grace_days, + DATE_ADD( + o.expires_at, + INTERVAL GREATEST(COALESCE(o.suspension_grace_days, p.suspension_grace_days, 0), 0) DAY + ) AS suspension_at + FROM client_order o + LEFT JOIN product p ON p.id = o.product_id + WHERE o.status = :status + AND o.expires_at IS NOT NULL + ) due + WHERE due.grace_days > 0 + AND due.suspension_at > NOW() + AND due.suspension_at <= DATE_ADD(NOW(), INTERVAL 1 DAY) + ORDER BY due.id + SQL, + ['status' => Order::STATUS_ACTIVE] + ); + + return array_map(static fn (array $row): array => [ + 'id' => (int) $row['id'], + 'suspension_at' => (string) $row['suspension_at'], + ], $rows); } /** diff --git a/src/modules/Order/Service.php b/src/modules/Order/Service.php index 73f38f47f..92040d163 100644 --- a/src/modules/Order/Service.php +++ b/src/modules/Order/Service.php @@ -23,12 +23,14 @@ use Box\Mod\Product\Entity\Product; use Box\Mod\Staff\Entity\Admin; use FOSSBilling\InformationException; use FOSSBilling\InjectionAwareInterface; +use FOSSBilling\Validation\NonNegativeIntegerValidator; use FOSSBilling\Validation\PriceValidator; use Symfony\Component\HttpFoundation\Response; class Service implements InjectionAwareInterface { public const META_CANCEL_AT_PERIOD_END = 'cancel_at_period_end'; + private const string META_SUSPENSION_WARNING_FOR = 'suspension_warning_for'; private const array BUILT_IN_SERVICE_TYPES = [ \Box\Mod\Product\Service::CUSTOM, @@ -172,6 +174,13 @@ class Service implements InjectionAwareInterface return $order instanceof Order ? $order->getConfig() : $order->config; } + private function orderSuspensionGraceDays(Order|\Model_ClientOrder $order): ?int + { + $graceDays = $order instanceof Order ? $order->getSuspensionGraceDays() : $order->suspension_grace_days; + + return $graceDays === null ? null : (int) $graceDays; + } + private function orderNotes(Order|\Model_ClientOrder $order): ?string { return $order instanceof Order ? $order->getNotes() : $order->notes; @@ -647,6 +656,7 @@ class Service implements InjectionAwareInterface 'reason' => $this->orderReason($model), 'notes' => $this->orderNotes($model), 'config' => $this->orderConfig($model), + 'suspension_grace_days' => $this->orderSuspensionGraceDays($model), 'referred_by' => $model instanceof Order ? $model->getReferredBy() : $model->referred_by, 'expires_at' => $model instanceof Order ? $model->getExpiresAt()?->format('Y-m-d H:i:s') : $model->expires_at, 'activated_at' => $model instanceof Order ? $model->getActivatedAt()?->format('Y-m-d H:i:s') : $model->activated_at, @@ -680,7 +690,11 @@ class Service implements InjectionAwareInterface if ($identity instanceof Admin) { $data['config'] = $this->getConfig($model); $productService = $this->di['mod_service']('product'); - $data['plugin'] = $productService->getProductPluginById((int) $this->orderProductId($model)); + $productId = $this->orderProductId($model); + $hasProduct = $productId !== null && $productId > 0; + $data['plugin'] = $hasProduct ? $productService->getProductPluginById($productId) : null; + $product = $hasProduct ? $productService->getProductRepository()->find($productId) : null; + $data['product_suspension_grace_days'] = $product instanceof Product ? $product->getSuspensionGraceDays() : null; } return $data; @@ -1341,7 +1355,7 @@ class Service implements InjectionAwareInterface return array_values(array_filter($addons, fn (Order $addon): bool => $addon->getId() !== $order->getId() && !$addon->isGroupMaster())); } - protected function _callOnService(Order|\Model_ClientOrder $order, $action) + protected function _callOnService(Order|\Model_ClientOrder $order, $action, mixed ...$arguments) { $serviceType = $this->orderServiceType($order); $serviceId = $this->orderServiceId($order); @@ -1355,7 +1369,7 @@ class Service implements InjectionAwareInterface throw new \FOSSBilling\Exception('Service ' . $serviceType . ' do not support ' . $m); } - return $repo->$m($order instanceof Order ? $this->getLegacyOrder($order) : $order); + return $repo->$m($order instanceof Order ? $this->getLegacyOrder($order) : $order, ...$arguments); } $o = $this->getLegacyOrder($order); @@ -1532,6 +1546,19 @@ class Service implements InjectionAwareInterface $notes = $data['notes'] ?? $this->orderNotes($order); $reason = $data['reason'] ?? $this->orderReason($order); + if (array_key_exists('suspension_grace_days', $data)) { + $graceDays = $data['suspension_grace_days'] === '' || $data['suspension_grace_days'] === null + ? null + : NonNegativeIntegerValidator::validate( + $data['suspension_grace_days'], + 'Suspension grace days must be a non-negative integer or empty to inherit the product setting.', + ); + if ($order instanceof Order) { + $order->setSuspensionGraceDays($graceDays); + } else { + $order->suspension_grace_days = $graceDays; + } + } if ($order instanceof Order) { $order->setNotes($notes); $order->setReason($reason); @@ -1660,7 +1687,8 @@ class Service implements InjectionAwareInterface throw new InformationException('Only active orders can be suspended'); } - $this->_callOnService($order, Order::ACTION_SUSPEND); + $arguments = $this->orderServiceType($order) === \Box\Mod\Product\Service::HOSTING ? [$reason] : []; + $this->_callOnService($order, Order::ACTION_SUSPEND, ...$arguments); if ($order instanceof Order) { $order->setStatus(Order::STATUS_SUSPENDED); @@ -1960,6 +1988,83 @@ class Service implements InjectionAwareInterface return $this->getOrderRepository()->getExpired(); } + public function batchSendSuspensionWarnings(): bool + { + $this->di['events_manager']->fire(['event' => 'onBeforeAdminBatchSendSuspensionWarnings']); + + $emailService = $this->di['mod_service']('email'); + foreach ($this->getOrderRepository()->getDueSuspensionWarnings() as $candidate) { + $order = $this->getOrderRepository()->find($candidate['id']); + if (!$order instanceof Order || !$this->claimSuspensionWarning($order, $candidate['suspension_at'])) { + continue; + } + + try { + $orderData = $this->toApiArray($order, false); + $orderData['suspension_at'] = $candidate['suspension_at']; + $emailService->sendTemplate([ + 'to_client' => $order->getClientId(), + 'code' => 'mod_order_suspension_warning', + 'order' => $orderData, + ]); + } catch (\Throwable $exception) { + $this->releaseSuspensionWarningClaim($order, $candidate['suspension_at']); + $this->di['logger']->setChannel('email')->error('Failed to send order suspension warning email', [ + 'order_id' => $order->getId(), + 'exception' => $exception->getMessage(), + ]); + } + } + + $this->di['events_manager']->fire(['event' => 'onAfterAdminBatchSendSuspensionWarnings']); + $this->di['logger']->info('Executed action to send order suspension warnings'); + + return true; + } + + private function claimSuspensionWarning(Order $order, string $suspensionAt): bool + { + $connection = $this->di['em']->getConnection(); + + return $connection->transactional(function () use ($connection, $order, $suspensionAt): bool { + $connection->fetchOne('SELECT id FROM client_order WHERE id = :id FOR UPDATE', ['id' => $order->getId()]); + $existing = $connection->fetchAssociative( + 'SELECT id, value FROM client_order_meta WHERE client_order_id = :order_id AND name = :name ORDER BY id LIMIT 1', + ['order_id' => $order->getId(), 'name' => self::META_SUSPENSION_WARNING_FOR] + ); + + if (is_array($existing) && $existing['value'] === $suspensionAt) { + return false; + } + + if (is_array($existing)) { + $connection->update('client_order_meta', [ + 'value' => $suspensionAt, + 'updated_at' => date('Y-m-d H:i:s'), + ], ['id' => $existing['id']]); + } else { + $connection->insert('client_order_meta', [ + 'client_order_id' => $order->getId(), + 'name' => self::META_SUSPENSION_WARNING_FOR, + 'value' => $suspensionAt, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + return true; + }); + } + + private function releaseSuspensionWarningClaim(Order $order, string $suspensionAt): void + { + $this->di['em']->getConnection()->delete('client_order_meta', [ + 'client_order_id' => $order->getId(), + 'name' => self::META_SUSPENSION_WARNING_FOR, + 'value' => $suspensionAt, + ]); + } + public function batchSuspendExpired(): bool { $this->di['events_manager']->fire(['event' => 'onBeforeAdminBatchSuspendOrders']); @@ -1967,7 +2072,8 @@ class Service implements InjectionAwareInterface $mod = $this->di['mod']('order'); $c = $mod->getConfig(); - $reason = $c['batch_suspend_reason'] ?? null; + $configuredReason = trim((string) ($c['batch_suspend_reason'] ?? '')); + $reason = $configuredReason !== '' ? $configuredReason : 'Non-payment'; $list = $this->getExpiredOrders(); diff --git a/src/modules/Order/templates/admin/mod_order_manage.html.twig b/src/modules/Order/templates/admin/mod_order_manage.html.twig index 9334f6d46..d0d2862f2 100644 --- a/src/modules/Order/templates/admin/mod_order_manage.html.twig +++ b/src/modules/Order/templates/admin/mod_order_manage.html.twig @@ -389,6 +389,21 @@ +
+ +
+ + {{ 'days'|trans }} +
+
{{ 'Leave empty to inherit the product setting (%days% days).'|trans({ '%days%': order.product_suspension_grace_days|default(0) }) }}
+
diff --git a/src/modules/Order/templates/email/mod_order_suspension_warning.html.twig b/src/modules/Order/templates/email/mod_order_suspension_warning.html.twig new file mode 100644 index 000000000..1375e0c33 --- /dev/null +++ b/src/modules/Order/templates/email/mod_order_suspension_warning.html.twig @@ -0,0 +1,18 @@ +{% block subject %}[{{ guest.system_company.name }}] Final suspension warning for {{ order.title|default('your service') }}{% endblock %} +{% block content %} +{% set order = order|default({}) %} + + + + + + +

Final Suspension Warning

+

Hello {{ c.first_name|default('') }} {{ c.last_name|default('') }},

+

Your {{ order.title|default('service') }} has expired and is scheduled to be suspended on {% if order.suspension_at is defined %}{{ order.suspension_at|format_datetime }}{% endif %}.

+

Please renew the service and settle any outstanding invoice before then to keep it active.

+

You may review your invoices or contact support.

+
{{ guest.system_email.signature }}
+ + +{% endblock %} diff --git a/src/modules/Order/tests/Unit/Api/AdminTest.php b/src/modules/Order/tests/Unit/Api/AdminTest.php index e3af8dfad..7f725c2f9 100644 --- a/src/modules/Order/tests/Unit/Api/AdminTest.php +++ b/src/modules/Order/tests/Unit/Api/AdminTest.php @@ -520,6 +520,16 @@ test('batch suspends expired orders', function (): void { expect($result)->toBeTrue(); }); +test('batch sends suspension warnings', function (): void { + $api = apiEndpoint(new Admin()); + + $serviceMock = Mockery::mock(Service::class); + $serviceMock->shouldReceive('batchSendSuspensionWarnings')->once()->andReturn(true); + $api->setService($serviceMock); + + expect($api->batch_send_suspension_warnings([]))->toBeTrue(); +}); + test('batch cancels suspended orders', function (): void { $api = apiEndpoint(new Admin()); diff --git a/src/modules/Order/tests/Unit/Entity/OrderTest.php b/src/modules/Order/tests/Unit/Entity/OrderTest.php index f5b3ae683..b6f613cb0 100644 --- a/src/modules/Order/tests/Unit/Entity/OrderTest.php +++ b/src/modules/Order/tests/Unit/Entity/OrderTest.php @@ -24,7 +24,7 @@ test('maps the existing order tables without changing their columns', function ( 'id', 'client_id', 'product_id', 'form_id', 'promo_id', 'promo_recurring', 'promo_used', 'group_id', 'group_master', 'invoice_option', 'title', 'currency', 'unpaid_invoice_id', 'service_id', 'service_type', 'period', 'quantity', 'unit', 'price', 'discount', 'status', - 'reason', 'notes', 'config', 'referred_by', 'expires_at', 'activated_at', 'suspended_at', + 'reason', 'notes', 'config', 'suspension_grace_days', 'referred_by', 'expires_at', 'activated_at', 'suspended_at', 'unsuspended_at', 'canceled_at', 'created_at', 'updated_at', ]) ->and($order->getFieldMapping('groupMaster')['nullable'])->toBeTrue() diff --git a/src/modules/Order/tests/Unit/Repository/OrderRepositoryTest.php b/src/modules/Order/tests/Unit/Repository/OrderRepositoryTest.php new file mode 100644 index 000000000..f29479b70 --- /dev/null +++ b/src/modules/Order/tests/Unit/Repository/OrderRepositoryTest.php @@ -0,0 +1,71 @@ +shouldReceive('fetchFirstColumn') + ->once() + ->withArgs(function (string $sql, array $parameters): bool { + $sql = normalizeOrderRepositorySql($sql); + + return $parameters === ['status' => Order::STATUS_ACTIVE] + && str_contains($sql, 'COALESCE(o.suspension_grace_days, p.suspension_grace_days, 0)') + && str_contains($sql, 'GREATEST(COALESCE(o.suspension_grace_days, p.suspension_grace_days, 0), 0)') + && str_contains($sql, ') <= NOW()'); + }) + ->andReturn([]); + + $entityManager = Mockery::mock(EntityManagerInterface::class); + $entityManager->shouldReceive('getConnection')->once()->andReturn($connection); + + $repository = new OrderRepository($entityManager, new ClassMetadata(Order::class)); + + expect($repository->getExpired())->toBe([]); +}); + +test('suspension warnings use positive grace periods in the final 24 hour window', function (): void { + $connection = Mockery::mock(Connection::class); + $connection->shouldReceive('fetchAllAssociative') + ->once() + ->withArgs(function (string $sql, array $parameters): bool { + $sql = normalizeOrderRepositorySql($sql); + + return $parameters === ['status' => Order::STATUS_ACTIVE] + && str_contains($sql, 'COALESCE(o.suspension_grace_days, p.suspension_grace_days, 0)') + && str_contains($sql, 'due.grace_days > 0') + && str_contains($sql, 'due.suspension_at > NOW()') + && str_contains($sql, 'due.suspension_at <= DATE_ADD(NOW(), INTERVAL 1 DAY)'); + }) + ->andReturn([ + ['id' => '8', 'suspension_at' => '2026-08-01 12:00:00'], + ]); + + $entityManager = Mockery::mock(EntityManagerInterface::class); + $entityManager->shouldReceive('getConnection')->once()->andReturn($connection); + + $repository = new OrderRepository($entityManager, new ClassMetadata(Order::class)); + + expect($repository->getDueSuspensionWarnings())->toBe([ + ['id' => 8, 'suspension_at' => '2026-08-01 12:00:00'], + ]); +}); diff --git a/src/modules/Order/tests/Unit/ServiceTest.php b/src/modules/Order/tests/Unit/ServiceTest.php index a3f21c038..cd4547afb 100644 --- a/src/modules/Order/tests/Unit/ServiceTest.php +++ b/src/modules/Order/tests/Unit/ServiceTest.php @@ -954,6 +954,7 @@ test('toApiArray returns expected keys', function (): void { 'quantity' => 1, 'client_id' => 1, ]); + $model->setProductId(1); $clientService = Mockery::mock(Box\Mod\Client\Service::class); $clientService->shouldReceive('toApiArray')->atLeast()->once()->andReturn([]); @@ -979,7 +980,10 @@ test('toApiArray returns expected keys', function (): void { $emMock->shouldIgnoreMissing(); $productService = Mockery::mock(Box\Mod\Product\Service::class); - $productService->shouldReceive('getProductPluginById')->once()->with((int) $model->product_id)->andReturn(null); + $productService->shouldReceive('getProductPluginById')->once()->with(1)->andReturn(null); + $productRepository = Mockery::mock(Box\Mod\Product\Repository\ProductRepository::class); + $productRepository->shouldReceive('find')->once()->with(1)->andReturn(null); + $productService->shouldReceive('getProductRepository')->once()->andReturn($productRepository); $di = container(); $di['mod_service'] = $di->protect(function ($serviceName) use ($clientService, $supportService, $productService) { @@ -1007,6 +1011,7 @@ test('toApiArray returns expected keys', function (): void { expect($result)->toHaveKey('meta'); expect($result)->toHaveKey('active_tickets'); expect($result)->toHaveKey('plugin'); + expect($result['product_suspension_grace_days'])->toBeNull(); expect($result)->toHaveKey('client'); }); @@ -2009,6 +2014,7 @@ test('updateOrder updates fields', function (): void { 'status' => 'active', 'notes' => 'Empty note', 'reason' => 'non', + 'suspension_grace_days' => 3, 'meta' => [], ]; @@ -2021,7 +2027,8 @@ test('updateOrder updates fields', function (): void { $result = $serviceMock->updateOrder($clientOrderModel, $data); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($clientOrderModel->getSuspensionGraceDays())->toBe(3); }); test('renewOrder renews order', function (): void { @@ -3032,7 +3039,7 @@ test('createOrder does not roll back when invoice generation fails for a negativ expect($result)->toBe($newId); }); -test('getExpiredOrders uses strict expires_at <= NOW() filter', function (): void { +test('getExpiredOrders delegates grace-aware selection to the repository', function (): void { $service = new Service(); $orderRepository = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class)->shouldIgnoreMissing(); @@ -3049,3 +3056,105 @@ test('getExpiredOrders uses strict expires_at <= NOW() filter', function (): voi expect($service->getExpiredOrders())->toBe([]); }); + +test('batchSendSuspensionWarnings claims and queues each warning once', function (): void { + $order = createEntity(Order::class, ['id' => 8, 'client_id' => 12]); + $repository = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class); + $repository->shouldReceive('getDueSuspensionWarnings')->twice()->andReturn([ + ['id' => 8, 'suspension_at' => '2026-08-01 12:00:00'], + ]); + $repository->shouldReceive('find')->twice()->with(8)->andReturn($order); + + $connection = Mockery::mock(Doctrine\DBAL\Connection::class); + $connection->shouldReceive('transactional')->twice()->andReturnUsing(fn (callable $callback): mixed => $callback()); + $connection->shouldReceive('fetchOne')->twice()->with( + 'SELECT id FROM client_order WHERE id = :id FOR UPDATE', + ['id' => 8] + )->andReturn(8); + $connection->shouldReceive('fetchAssociative')->twice()->andReturn( + false, + ['id' => 14, 'value' => '2026-08-01 12:00:00'] + ); + $connection->shouldReceive('insert')->once()->with('client_order_meta', Mockery::on( + fn (array $data): bool => $data['client_order_id'] === 8 + && $data['name'] === 'suspension_warning_for' + && $data['value'] === '2026-08-01 12:00:00' + )); + + $em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class); + $em->shouldReceive('getRepository')->with(Order::class)->once()->andReturn($repository); + $em->shouldReceive('getConnection')->twice()->andReturn($connection); + + $emailService = Mockery::mock(Box\Mod\Email\Service::class); + $emailService->shouldReceive('sendTemplate')->once()->with(Mockery::on( + fn (array $email): bool => $email['to_client'] === 12 + && $email['code'] === 'mod_order_suspension_warning' + && $email['order']['suspension_at'] === '2026-08-01 12:00:00' + ))->andReturn(true); + + $events = Mockery::mock(Box_EventManager::class); + $events->shouldReceive('fire')->times(4); + + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('toApiArray')->once()->with($order, false)->andReturn(['id' => 8]); + + $di = container(); + $di['em'] = $em; + $di['events_manager'] = $events; + $di['logger'] = new Box_Log(); + $di['mod_service'] = $di->protect(fn (string $name): Box\Mod\Email\Service => $emailService); + $service->setDi($di); + + expect($service->batchSendSuspensionWarnings())->toBeTrue() + ->and($service->batchSendSuspensionWarnings())->toBeTrue(); +}); + +test('batchSendSuspensionWarnings releases a failed claim so the warning can be retried', function (): void { + $order = createEntity(Order::class, ['id' => 8, 'client_id' => 12]); + $candidate = ['id' => 8, 'suspension_at' => '2026-08-01 12:00:00']; + $repository = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class); + $repository->shouldReceive('getDueSuspensionWarnings')->twice()->andReturn([$candidate]); + $repository->shouldReceive('find')->twice()->with(8)->andReturn($order); + + $connection = Mockery::mock(Doctrine\DBAL\Connection::class); + $connection->shouldReceive('transactional')->twice()->andReturnUsing(fn (callable $callback): mixed => $callback()); + $connection->shouldReceive('fetchOne')->twice()->andReturn(8); + $connection->shouldReceive('fetchAssociative')->twice()->andReturn(false); + $connection->shouldReceive('insert')->twice(); + $connection->shouldReceive('delete')->once()->with('client_order_meta', [ + 'client_order_id' => 8, + 'name' => 'suspension_warning_for', + 'value' => $candidate['suspension_at'], + ]); + + $em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class); + $em->shouldReceive('getRepository')->with(Order::class)->once()->andReturn($repository); + $em->shouldReceive('getConnection')->times(3)->andReturn($connection); + + $attempts = 0; + $emailService = Mockery::mock(Box\Mod\Email\Service::class); + $emailService->shouldReceive('sendTemplate')->twice()->andReturnUsing(function () use (&$attempts): bool { + if (++$attempts === 1) { + throw new RuntimeException('Queue unavailable'); + } + + return true; + }); + + $events = Mockery::mock(Box_EventManager::class); + $events->shouldReceive('fire')->times(4); + + $service = Mockery::mock(Service::class)->makePartial(); + $service->shouldReceive('toApiArray')->twice()->with($order, false)->andReturn(['id' => 8]); + + $di = container(); + $di['em'] = $em; + $di['events_manager'] = $events; + $di['logger'] = new Box_Log(); + $di['mod_service'] = $di->protect(fn (string $name): Box\Mod\Email\Service => $emailService); + $service->setDi($di); + + expect($service->batchSendSuspensionWarnings())->toBeTrue() + ->and($service->batchSendSuspensionWarnings())->toBeTrue() + ->and($attempts)->toBe(2); +}); diff --git a/src/modules/Product/Api/Admin.php b/src/modules/Product/Api/Admin.php index 62264fcf0..08c329700 100644 --- a/src/modules/Product/Api/Admin.php +++ b/src/modules/Product/Api/Admin.php @@ -125,6 +125,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi * @optional bool $stock_control - product stock control flag. * @optional bool $allow_quantity_select - client can select product quantity on order form flag * @optional bool $quantity_in_stock - quantity available for sale. When out of stock, new order cannot be placed. + * @optional int $suspension_grace_days - days after service expiration before automatic suspension * * @return bool * diff --git a/src/modules/Product/Entity/Product.php b/src/modules/Product/Entity/Product.php index 0c00a83ed..1f8c59d1a 100644 --- a/src/modules/Product/Entity/Product.php +++ b/src/modules/Product/Entity/Product.php @@ -79,6 +79,9 @@ class Product implements TimestampInterface #[ORM\Column(name: 'quantity_in_stock', type: \Doctrine\DBAL\Types\Types::INTEGER, options: ['default' => 0])] private int $quantityInStock = 0; + #[ORM\Column(name: 'suspension_grace_days', type: \Doctrine\DBAL\Types\Types::INTEGER, options: ['default' => 0])] + private int $suspensionGraceDays = 0; + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255, nullable: true)] private ?string $plugin = null; @@ -306,6 +309,18 @@ class Product implements TimestampInterface return $this; } + public function getSuspensionGraceDays(): int + { + return $this->suspensionGraceDays; + } + + public function setSuspensionGraceDays(int $suspensionGraceDays): self + { + $this->suspensionGraceDays = $suspensionGraceDays; + + return $this; + } + public function getPlugin(): ?string { return $this->plugin; diff --git a/src/modules/Product/Service.php b/src/modules/Product/Service.php index 7e82ddb2b..d2b34553e 100644 --- a/src/modules/Product/Service.php +++ b/src/modules/Product/Service.php @@ -32,6 +32,7 @@ use Doctrine\DBAL\Connection; use Doctrine\ORM\QueryBuilder; use FOSSBilling\InjectionAwareInterface; use FOSSBilling\PaginationOptions; +use FOSSBilling\Validation\NonNegativeIntegerValidator; class Service implements InjectionAwareInterface { @@ -218,6 +219,7 @@ class Service implements InjectionAwareInterface if ($isAdmin) { $result['created_at'] = $this->formatDateTimeValue($model->getCreatedAt()); $result['updated_at'] = $this->formatDateTimeValue($model->getUpdatedAt()); + $result['suspension_grace_days'] = $model->getSuspensionGraceDays(); $result['addons'] = $addons; $result['quantity_in_stock'] = $model->getQuantityInStock(); $result['stock_control'] = $model->isStockControl(); @@ -440,6 +442,14 @@ class Service implements InjectionAwareInterface public function updateProduct(Product $model, $data): bool { + if (array_key_exists('suspension_grace_days', $data)) { + $graceDays = NonNegativeIntegerValidator::validate( + $data['suspension_grace_days'], + 'Suspension grace days must be a non-negative integer.', + ); + $model->setSuspensionGraceDays($graceDays); + } + // pricing if (isset($data['pricing'])) { $types = $this->getPaymentTypes(); diff --git a/src/modules/Product/templates/admin/mod_product_manage.html.twig b/src/modules/Product/templates/admin/mod_product_manage.html.twig index c0fee7049..217183f05 100644 --- a/src/modules/Product/templates/admin/mod_product_manage.html.twig +++ b/src/modules/Product/templates/admin/mod_product_manage.html.twig @@ -88,6 +88,14 @@
+
+ +
+ + {{ 'days'|trans }} +
+
{{ 'Keep services active for this many days after expiration before automatic suspension.'|trans }}
+
diff --git a/src/modules/Product/tests/Unit/ServiceTest.php b/src/modules/Product/tests/Unit/ServiceTest.php index 626a19c64..ddaea434e 100644 --- a/src/modules/Product/tests/Unit/ServiceTest.php +++ b/src/modules/Product/tests/Unit/ServiceTest.php @@ -772,6 +772,7 @@ test('update product', function (): void { 'stock_control' => false, 'allow_quantity_select' => false, 'quantity_in_stock' => 0, + 'suspension_grace_days' => 5, 'description' => 'Product description', 'plugin' => 'plug in', ]; @@ -790,9 +791,18 @@ test('update product', function (): void { $serviceMock->setDi($di); $result = $serviceMock->updateProduct($modelProduct, $data); - expect($result)->toBeTrue(); + expect($result)->toBeTrue() + ->and($modelProduct->getSuspensionGraceDays())->toBe(5); }); +test('update product rejects invalid suspension grace days', function (mixed $invalidGraceDays): void { + $service = new Service(); + $product = productTestCreateProductEntity(1); + + expect(fn () => $service->updateProduct($product, ['suspension_grace_days' => $invalidGraceDays])) + ->toThrow(FOSSBilling\InformationException::class, 'Suspension grace days must be a non-negative integer.'); +})->with([-1, '-1', '1.5', '01', PHP_INT_MAX . '0']); + test('update priority', function (): void { $service = new Service(); $data = [ diff --git a/src/modules/Servicehosting/Service.php b/src/modules/Servicehosting/Service.php index cae393e63..8b1232015 100644 --- a/src/modules/Servicehosting/Service.php +++ b/src/modules/Servicehosting/Service.php @@ -239,10 +239,11 @@ class Service implements InjectionAwareInterface /** * @throws Exception */ - public function action_suspend(\Model_ClientOrder $order): bool + public function action_suspend(\Model_ClientOrder $order, ?string $reason = null): bool { $model = $this->_getOrderService($order); [$adapter, $account] = $this->_getAM($model); + $account->setNote($reason); $adapter->suspendAccount($account); $this->di['em']->flush(); diff --git a/src/modules/Servicehosting/tests/Unit/ServiceTest.php b/src/modules/Servicehosting/tests/Unit/ServiceTest.php index 4dc4a3041..4ab0c8b33 100644 --- a/src/modules/Servicehosting/tests/Unit/ServiceTest.php +++ b/src/modules/Servicehosting/tests/Unit/ServiceTest.php @@ -234,12 +234,14 @@ test('action suspend', function (): void { $serviceMock = Mockery::mock(Service::class)->makePartial(); $serverManagerMock = Mockery::mock('\Server_Manager_Custom'); - $serverManagerMock->shouldReceive('suspendAccount')->atLeast()->once(); + $serverManagerMock->shouldReceive('suspendAccount')->once()->with(Mockery::on( + fn (Server_Account $account): bool => $account->getNote() === 'Non-payment' + )); $AMresultArray = [$serverManagerMock, new Server_Account()]; $serviceMock->shouldReceive('_getAM')->atLeast()->once()->andReturn($AMresultArray); $serviceMock->setDi($di); - $result = $serviceMock->action_suspend($orderModel); + $result = $serviceMock->action_suspend($orderModel, 'Non-payment'); expect($result)->toBeTrue(); }); diff --git a/src/modules/Staff/Service.php b/src/modules/Staff/Service.php index 727f054fa..bc49002e2 100644 --- a/src/modules/Staff/Service.php +++ b/src/modules/Staff/Service.php @@ -261,6 +261,31 @@ class Service implements InjectionAwareInterface } } + public static function onAfterAdminOrderSuspend(\Box_Event $event): void + { + $di = $event->getDi(); + $params = $event->getParameters(); + + try { + $order = $di['em']->getRepository(\Box\Mod\Order\Entity\Order::class)->find((int) $params['id']); + if (!$order instanceof \Box\Mod\Order\Entity\Order) { + throw new \FOSSBilling\Exception('Order not found'); + } + + $orderService = $di['mod_service']('order'); + $emailService = $di['mod_service']('email'); + $emailService->sendTemplate([ + 'to_staff' => true, + 'code' => 'mod_staff_order_suspended', + 'order' => $orderService->toApiArray($order, false), + ]); + } catch (\Throwable $exception) { + $di['logger']->setChannel('email')->error('Failed to send staff order suspension notification email', [ + 'exception' => $exception->getMessage(), + ]); + } + } + public static function onAfterClientOpenTicket(\Box_Event $event): void { $di = $event->getDi(); diff --git a/src/modules/Staff/templates/email/mod_staff_order_suspended.html.twig b/src/modules/Staff/templates/email/mod_staff_order_suspended.html.twig new file mode 100644 index 000000000..ea9c24b2a --- /dev/null +++ b/src/modules/Staff/templates/email/mod_staff_order_suspended.html.twig @@ -0,0 +1,20 @@ +{% block subject %}[{{ guest.system_company.name }}] Order #{{ order.id|default('') }} suspended{% endblock %} +{% block content %} +{% set order = order|default({}) %} + + + + + + +

Order Suspended

+

Hello {{ staff.name|default('') }},

+

The service {{ order.title|default('') }} for {{ order.client.first_name|default('') }} {{ order.client.last_name|default('') }} has been suspended.

+ {% if order.reason|default('') %} +

Reason: {{ order.reason }}

+ {% endif %} +

Manage order

+
{{ guest.system_email.signature }}
+ + +{% endblock %} diff --git a/src/modules/Staff/tests/Unit/ServiceTest.php b/src/modules/Staff/tests/Unit/ServiceTest.php index 9d083c205..331651a11 100644 --- a/src/modules/Staff/tests/Unit/ServiceTest.php +++ b/src/modules/Staff/tests/Unit/ServiceTest.php @@ -325,6 +325,42 @@ test('hasPermission returns false for staff without method permission', function expect($result)->toBeFalse(); }); +test('onAfterAdminOrderSuspend sends a staff notification', function (): void { + $order = Tests\Helpers\createEntity(Box\Mod\Order\Entity\Order::class, ['id' => 42]); + $orderRepository = Mockery::mock(Box\Mod\Order\Repository\OrderRepository::class); + $orderRepository->shouldReceive('find')->once()->with(42)->andReturn($order); + + $entityManager = Mockery::mock(EntityManagerInterface::class); + $entityManager->shouldReceive('getRepository') + ->once() + ->with(Box\Mod\Order\Entity\Order::class) + ->andReturn($orderRepository); + + $orderData = ['id' => 42, 'title' => 'Hosting', 'reason' => 'Non-payment']; + $orderService = Mockery::mock(Box\Mod\Order\Service::class); + $orderService->shouldReceive('toApiArray')->once()->with($order, false)->andReturn($orderData); + + $emailService = Mockery::mock(Box\Mod\Email\Service::class); + $emailService->shouldReceive('sendTemplate')->once()->with([ + 'to_staff' => true, + 'code' => 'mod_staff_order_suspended', + 'order' => $orderData, + ]); + + $di = container(); + $di['em'] = $entityManager; + $di['mod_service'] = $di->protect(fn (string $name): object => match ($name) { + 'order' => $orderService, + 'email' => $emailService, + }); + + $event = Mockery::mock(Box_Event::class); + $event->shouldReceive('getDi')->once()->andReturn($di); + $event->shouldReceive('getParameters')->once()->andReturn(['id' => 42]); + + Service::onAfterAdminOrderSuspend($event); +}); + test('onAfterClientReplyTicket sends email notification', function (): void { $eventMock = Mockery::mock('\Box_Event'); $ticketId = 42; diff --git a/tests/Unit/FOSSBilling/UpdatePatcherTest.php b/tests/Unit/FOSSBilling/UpdatePatcherTest.php index 8221e12f7..7bfc8ccdf 100644 --- a/tests/Unit/FOSSBilling/UpdatePatcherTest.php +++ b/tests/Unit/FOSSBilling/UpdatePatcherTest.php @@ -22,11 +22,17 @@ test('downloadable file migration follows the client balance gateway repair', fu test('manual currency rate patch follows the currency formatting patch', function (): void { $patches = (new ReflectionMethod(UpdatePatcher::class, 'getPatches'))->invoke(new UpdatePatcher(), 93); - expect($patches)->toHaveCount(1) - ->toHaveKey(94) + expect($patches)->toHaveKey(94) ->and($patches[94][1])->toBe('patch94'); }); +test('suspension grace patch follows the manual currency rate patch', function (): void { + $patches = (new ReflectionMethod(UpdatePatcher::class, 'getPatches'))->invoke(new UpdatePatcher(), 94); + + expect($patches)->toHaveKey(95) + ->and($patches[95][1])->toBe('patch95'); +}); + test('fresh installs start at the latest patch level', function (): void { $content = file_get_contents(Path::join(PATH_ROOT, 'install', 'sql', 'content.sql')); expect($content)->toBeString(); @@ -36,6 +42,49 @@ test('fresh installs start at the latest patch level', function (): void { expect((int) ($matches[1] ?? 0))->toBe((new UpdatePatcher())->latestPatchLevel()); }); +test('fresh installs index order suspension candidates', function (): void { + $filesystem = new Filesystem(); + $structure = $filesystem->readFile(Path::join(PATH_ROOT, 'install', 'sql', 'structure.sql')); + + expect($structure)->toContain('KEY `client_order_status_expires_at_idx` (`status`, `expires_at`)'); +}); + +test('suspension grace patch indexes existing order tables', function (): void { + $productColumns = Mockery::mock(PDOStatement::class); + $productColumns->expects('execute')->with([])->andReturnTrue(); + $productColumns->expects('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([ + ['Field' => 'suspension_grace_days'], + ]); + + $orderColumns = Mockery::mock(PDOStatement::class); + $orderColumns->expects('execute')->with([])->andReturnTrue(); + $orderColumns->expects('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([ + ['Field' => 'suspension_grace_days'], + ]); + + $orderIndexes = Mockery::mock(PDOStatement::class); + $orderIndexes->expects('execute')->with([])->andReturnTrue(); + $orderIndexes->expects('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([]); + + $addIndex = Mockery::mock(PDOStatement::class); + $addIndex->expects('execute')->with([])->andReturnTrue(); + + $pdo = Mockery::mock(PDO::class); + $pdo->expects('prepare')->with('SHOW COLUMNS FROM `product`')->andReturn($productColumns); + $pdo->expects('prepare')->with('SHOW COLUMNS FROM `client_order`')->andReturn($orderColumns); + $pdo->expects('prepare')->with('SHOW INDEX FROM `client_order`')->andReturn($orderIndexes); + $pdo->expects('prepare') + ->with('ALTER TABLE `client_order` ADD INDEX `client_order_status_expires_at_idx` (`status`, `expires_at`)') + ->andReturn($addIndex); + + $di = new Pimple\Container(); + $di['pdo'] = $pdo; + + $patcher = new UpdatePatcher(); + $patcher->setDi($di); + (new ReflectionMethod($patcher, 'patch95'))->invoke($patcher); +}); + test('client balance gateway patch restores one-time payments', function (): void { $statement = Mockery::mock(PDOStatement::class); $statement->expects('execute') diff --git a/tests/Unit/FOSSBilling/Validation/NonNegativeIntegerValidatorTest.php b/tests/Unit/FOSSBilling/Validation/NonNegativeIntegerValidatorTest.php new file mode 100644 index 000000000..7ae517f94 --- /dev/null +++ b/tests/Unit/FOSSBilling/Validation/NonNegativeIntegerValidatorTest.php @@ -0,0 +1,30 @@ +toBe($expected); +})->with([ + [0, 0], + [5, 5], + ['0', 0], + ['5', 5], + [(string) PHP_INT_MAX, PHP_INT_MAX], +]); + +test('rejects values that are not non-negative integers', function (mixed $value): void { + expect(fn (): int => NonNegativeIntegerValidator::validate($value, 'Invalid value')) + ->toThrow(InformationException::class, 'Invalid value'); +})->with([ + -1, + '-1', + 1.5, + '1.5', + '01', + PHP_INT_MAX . '0', + true, + null, +]); diff --git a/tests/Unit/Server/Manager/DirectadminTest.php b/tests/Unit/Server/Manager/DirectadminTest.php index 993daf78d..83a6b5cee 100644 --- a/tests/Unit/Server/Manager/DirectadminTest.php +++ b/tests/Unit/Server/Manager/DirectadminTest.php @@ -2,6 +2,10 @@ declare(strict_types=1); +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\HttpClientInterface; + function invokeDirectadminParseResponse(Server_Manager_Directadmin $manager, string $data): array { $reflection = new ReflectionClass($manager); @@ -10,6 +14,21 @@ function invokeDirectadminParseResponse(Server_Manager_Directadmin $manager, str return $method->invokeArgs($manager, [$data]); } +function createDirectadminManager(HttpClientInterface $httpClient): Server_Manager_Directadmin +{ + return new class(['host' => 'directadmin.example.com', 'username' => 'admin', 'password' => 'secret'], $httpClient) extends Server_Manager_Directadmin { + public function __construct(array $options, private readonly HttpClientInterface $httpClient) + { + parent::__construct($options); + } + + public function getHttpClient(): HttpClientInterface + { + return $this->httpClient; + } + }; +} + beforeEach(function (): void { $this->manager = new Server_Manager_Directadmin([ 'host' => 'directadmin.example.com', @@ -29,3 +48,67 @@ test('parseResponse decodes the legacy unterminated apostrophe entity', function expect($result['name'])->toBe("O'Brien"); }); + +test('suspendAccount sends the suspension reason to DirectAdmin', function (): void { + $requests = []; + $httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = ['method' => $method, 'url' => $url, 'options' => $options]; + + return new MockResponse(str_contains($url, 'CMD_API_SHOW_USER_CONFIG') ? 'suspended=no' : ''); + }); + $manager = createDirectadminManager($httpClient); + $account = (new Server_Account()) + ->setUsername('example') + ->setNote('Non-payment'); + + expect($manager->suspendAccount($account))->toBeTrue() + ->and($requests)->toHaveCount(2); + + parse_str((string) parse_url($requests[1]['url'], PHP_URL_QUERY), $fields); + + expect($requests[1]['method'])->toBe('POST') + ->and($requests[1]['url'])->toContain('CMD_API_SELECT_USERS') + ->and($fields['reason'])->toBe('billing') + ->and($fields['details'])->toBe('Non-payment'); +}); + +test('suspendAccount maps a custom suspension note to other', function (): void { + $requests = []; + $httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = ['method' => $method, 'url' => $url, 'options' => $options]; + + return new MockResponse(str_contains($url, 'CMD_API_SHOW_USER_CONFIG') ? 'suspended=no' : ''); + }); + $manager = createDirectadminManager($httpClient); + $account = (new Server_Account()) + ->setUsername('example') + ->setNote('Terms of service violation'); + + expect($manager->suspendAccount($account))->toBeTrue(); + + parse_str((string) parse_url($requests[1]['url'], PHP_URL_QUERY), $fields); + + expect($fields['reason'])->toBe('other') + ->and($fields['details'])->toBe('Terms of service violation'); +}); + +test('suspendAccount omits an empty suspension reason and details', function (?string $reason): void { + $requests = []; + $httpClient = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = ['method' => $method, 'url' => $url, 'options' => $options]; + + return new MockResponse(str_contains($url, 'CMD_API_SHOW_USER_CONFIG') ? 'suspended=no' : ''); + }); + $manager = createDirectadminManager($httpClient); + $account = (new Server_Account()) + ->setUsername('example') + ->setNote($reason); + + expect($manager->suspendAccount($account))->toBeTrue() + ->and($requests)->toHaveCount(2); + + parse_str((string) parse_url($requests[1]['url'], PHP_URL_QUERY), $fields); + + expect($fields)->not->toHaveKey('reason') + ->not->toHaveKey('details'); +})->with([null, '']);