Improve overdue order suspension handling (#4072)

* Improve account suspension workflow

* Address suspension workflow review feedback

* Refine update patcher tests
This commit is contained in:
Adam Daley authored and GitHub committed 2026-07-28 19:19:44 +01:00
1 parent 3acbfc3aa1
commit adcd85f52b
30 files changed
+774 -25

No files matched your search

+1 -1
View File
@@ -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()),
+4 -1
View File
@@ -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,
+16
View File
@@ -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 {
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
namespace FOSSBilling\Validation;
use FOSSBilling\InformationException;
final class NonNegativeIntegerValidator
{
public static function validate(mixed $value, string $message): int
{
if (is_int($value) && $value >= 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);
}
}
@@ -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);
+1
View File
@@ -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',
@@ -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',
+13
View File
@@ -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.
+14
View File
@@ -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;
@@ -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<int, array{id: int, suspension_at: string}>
*/
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);
}
/**
+111 -5
View File
@@ -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();
@@ -389,6 +389,21 @@
</span>
</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="order_suspension_grace_days">{{ 'Suspension Grace Period Override'|trans }}</label>
<div class="input-group">
<input class="form-control"
id="order_suspension_grace_days"
type="number"
min="0"
step="1"
name="suspension_grace_days"
value="{{ order.suspension_grace_days|default('') }}"
placeholder="{{ order.product_suspension_grace_days|default(0) }}">
<span class="input-group-text">{{ 'days'|trans }}</span>
</div>
<div class="form-text">{{ 'Leave empty to inherit the product setting (%days% days).'|trans({ '%days%': order.product_suspension_grace_days|default(0) }) }}</div>
</div>
<div class="col-lg-6">
<label class="form-label" for="order_reason">{{ 'Reason'|trans }}</label>
<input class="form-control" id="order_reason" type="text" name="reason" value="{{ order.reason }}">
@@ -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({}) %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<h1>Final Suspension Warning</h1>
<p>Hello {{ c.first_name|default('') }} {{ c.last_name|default('') }},</p>
<p>Your <strong>{{ order.title|default('service') }}</strong> has expired and is scheduled to be suspended on <strong>{% if order.suspension_at is defined %}{{ order.suspension_at|format_datetime }}{% endif %}</strong>.</p>
<p>Please renew the service and settle any outstanding invoice before then to keep it active.</p>
<p>You may <a href="{{ 'invoice'|url(area: 'client') }}" target="_blank">review your invoices</a> or <a href="{{ 'support'|url(query: {ticket: 1}, area: 'client') }}" target="_blank">contact support</a>.</p>
<div class="signature">{{ guest.system_email.signature }}</div>
</body>
</html>
{% endblock %}
@@ -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());
@@ -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()
@@ -0,0 +1,71 @@
<?php
/**
* Copyright 2022-2026 FOSSBilling
* SPDX-License-Identifier: Apache-2.0.
*
* @copyright FOSSBilling (https://www.fossbilling.org)
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
*/
declare(strict_types=1);
use Box\Mod\Order\Entity\Order;
use Box\Mod\Order\Repository\OrderRepository;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
function normalizeOrderRepositorySql(string $sql): string
{
return preg_replace('/\s+/', ' ', trim($sql));
}
test('expired orders use the order override before the product grace period', function (): void {
$connection = Mockery::mock(Connection::class);
$connection->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'],
]);
});
+112 -3
View File
@@ -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);
});
+1
View File
@@ -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
*
+15
View File
@@ -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;
+10
View File
@@ -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();
@@ -88,6 +88,14 @@
<option value="manual"{% if product.setup == 'manual' %} selected{% endif %}>{{ 'Manual Activation'|trans }}</option>
</select>
</div>
<div class="col-lg-6">
<label class="form-label" for="suspension_grace_days">{{ 'Suspension Grace Period'|trans }}</label>
<div class="input-group">
<input class="form-control" id="suspension_grace_days" type="number" min="0" step="1" name="suspension_grace_days" value="{{ product.suspension_grace_days|default(0) }}">
<span class="input-group-text">{{ 'days'|trans }}</span>
</div>
<div class="form-text">{{ 'Keep services active for this many days after expiration before automatic suspension.'|trans }}</div>
</div>
<div class="col-12">
<input type="hidden" name="hidden" value="0">
<label class="form-label d-block">{{ 'Storefront Visibility'|trans }}</label>
+11 -1
View File
@@ -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 = [
+2 -1
View File
@@ -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();
@@ -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();
});
+25
View File
@@ -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();
@@ -0,0 +1,20 @@
{% block subject %}[{{ guest.system_company.name }}] Order #{{ order.id|default('') }} suspended{% endblock %}
{% block content %}
{% set order = order|default({}) %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<h1>Order Suspended</h1>
<p>Hello {{ staff.name|default('') }},</p>
<p>The service <strong>{{ order.title|default('') }}</strong> for <strong>{{ order.client.first_name|default('') }} {{ order.client.last_name|default('') }}</strong> has been suspended.</p>
{% if order.reason|default('') %}
<p><strong>Reason:</strong> {{ order.reason }}</p>
{% endif %}
<p><a href="{{ "order/manage/#{order.id|default('')}"|url(area: 'admin') }}" target="_blank">Manage order</a></p>
<div class="signature">{{ guest.system_email.signature }}</div>
</body>
</html>
{% endblock %}
@@ -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;
+51 -2
View File
@@ -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')
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use FOSSBilling\InformationException;
use FOSSBilling\Validation\NonNegativeIntegerValidator;
test('validates non-negative integers', function (mixed $value, int $expected): void {
expect(NonNegativeIntegerValidator::validate($value, 'Invalid value'))->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,
]);
@@ -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, '']);