mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-07 04:23:17 +09:00
Improve overdue order suspension handling (#4072)
* Improve account suspension workflow * Address suspension workflow review feedback * Refine update patcher tests
This commit is contained in:
1 parent
3acbfc3aa1
commit
adcd85f52b
30 files changed
+774
-25
No files matched your search
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '']);
|
||||
Reference in new issue
Block a user