Support collected invoice economic PDF downloads

This commit is contained in:
Jeppe B
2026-07-14 15:39:39 +02:00
parent 69b3bf83c4
commit 0feb705059
23 changed files with 3253 additions and 136 deletions
+64
View File
@@ -7196,6 +7196,45 @@ paths:
'500': '500':
$ref: '#/components/responses/InternalServerError' $ref: '#/components/responses/InternalServerError'
/collected-invoices/economic/pdf:
get:
tags:
- Invoices
summary: Download a collected invoice e-conomic PDF
description: |
Resolves the requested collected invoice context and returns a presigned URL for the
draft or booked e-conomic invoice PDF.
operationId: downloadCollectedInvoiceEconomicPdf
parameters:
- name: collected_invoice_id
in: query
required: true
description: The internal collected invoice ID
schema:
type: integer
minimum: 1
- name: type
in: query
required: true
description: Which e-conomic invoice PDF to download
schema:
type: string
enum:
- draft
- booked
responses:
'200':
description: PDF URL resolved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/CollectedInvoiceEconomicPdfResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/v2/details: /collected-invoices/economic/v2/details:
get: get:
tags: tags:
@@ -17750,6 +17789,31 @@ components:
- collected_invoice_id - collected_invoice_id
- internal_total - internal_total
CollectedInvoiceEconomicPdfResponse:
type: object
description: Presigned PDF URL for a draft or booked e-conomic invoice attached to a collected invoice
properties:
collected_invoice_id:
type: integer
example: 123
type:
type: string
enum:
- draft
- booked
example: booked
economic_invoice_id:
type: integer
example: 28368
url:
type: string
format: uri
required:
- collected_invoice_id
- type
- economic_invoice_id
- url
CollectedInvoiceEconomicV2DetailsResponse: CollectedInvoiceEconomicV2DetailsResponse:
type: object type: object
properties: properties:
+22 -3
View File
@@ -11,6 +11,7 @@ use fxratesapi\actions\convert_rate_a;
use fxratesapi\fxratesapi_c; use fxratesapi\fxratesapi_c;
use interfaces\fxratesapi_i; use interfaces\fxratesapi_i;
use objects\fxratesapi_conversion_rates_o; use objects\fxratesapi_conversion_rates_o;
use Throwable;
class fxratesapi implements fxratesapi_i class fxratesapi implements fxratesapi_i
{ {
@@ -117,10 +118,10 @@ class fxratesapi implements fxratesapi_i
// Validate the base and target currencies // Validate the base and target currencies
self::requireValidCurrency($base); self::requireValidCurrency($base);
self::requireValidCurrency($target); self::requireValidCurrency($target);
// Validate the daily limit
self::requireDailyLimitNotExceeded();
// Validate the secret key // Validate the secret key
self::requireValidSecretKey(); self::requireValidSecretKey();
// Reserve quota for the outbound provider call. Cached conversion reads return before this point.
$this->reserveRateFetchQuota($base, $target, $endpoint, $method);
// Send the request // Send the request
$response = match ($method) { $response = match ($method) {
'GET' => self::sendGetRequest($base, $target, $endpoint, $data), 'GET' => self::sendGetRequest($base, $target, $endpoint, $data),
@@ -167,7 +168,7 @@ class fxratesapi implements fxratesapi_i
function requireDailyLimitNotExceeded(): void function requireDailyLimitNotExceeded(): void
{ {
// Check if the daily limit is exceeded // Check if the daily limit is exceeded
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) { if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
throw new Exception('Daily limit exceeded'); throw new Exception('Daily limit exceeded');
} }
} }
@@ -177,12 +178,30 @@ class fxratesapi implements fxratesapi_i
*/ */
function getDailyRequestCounter(): int function getDailyRequestCounter(): int
{ {
try {
return (new module_usage_service())->currentUsedQuantity('fxratesapi', 'rate_fetch_calls');
} catch (Throwable) {
}
// Count the rows from the fxratesapi request log that was made today // Count the rows from the fxratesapi request log that was made today
$fxratesapi_lookups = new fxratesapi_conversion_rates_o(); $fxratesapi_lookups = new fxratesapi_conversion_rates_o();
$fxratesapi_lookups->getTodayCount(); $fxratesapi_lookups->getTodayCount();
return $fxratesapi_lookups->getTodayCount(); return $fxratesapi_lookups->getTodayCount();
} }
/**
* @throws Exception
*/
private function reserveRateFetchQuota(string $base, string $target, string $endpoint, string $method): void
{
(new module_usage_service())->reserveOrFail('fxratesapi', 'rate_fetch_calls', 1, [
'base' => $base,
'target' => $target,
'endpoint' => $endpoint,
'method' => strtoupper($method),
]);
}
/** /**
* @inheritDoc * @inheritDoc
*/ */
+1 -1
View File
@@ -27,7 +27,7 @@ class invoice_store implements minio_invoices_i
return count($objects['Contents'] ?? []) > 0; return count($objects['Contents'] ?? []) > 0;
} }
public function getInvoiceDownloadUrl(int $id): string public function getInvoiceDownloadUrl(int|string $id): string
{ {
return self::getPresignedUrl('invoice_' . $id . '.pdf'); return self::getPresignedUrl('invoice_' . $id . '.pdf');
} }
@@ -0,0 +1,169 @@
<?php
namespace classes;
class module_usage_registry
{
public const DEFAULT_SOFT_LIMIT_PERCENT = 90.0;
public function all(): array
{
return array_map(
fn(array $descriptor): array => $this->withDefaults($descriptor),
[
[
'module_key' => 'motorapi',
'module_label' => 'MotorAPI',
'metric_key' => 'lookup_calls',
'metric_label' => 'License plate lookups',
'unit' => 'calls',
'period' => 'day',
'source' => 'internal_counter',
'default_enforce_mode' => 'block',
'config_module' => 'motorapi',
'config_variable' => 'daily_limit',
'config_type' => 'int',
'legacy_count_table' => 'motorapi_lookups',
'primary' => true,
'writable_limit' => true,
],
[
'module_key' => 'motorapi',
'module_label' => 'MotorAPI',
'metric_key' => 'provider_usage',
'metric_label' => 'Provider usage',
'unit' => 'calls',
'period' => 'provider',
'source' => 'provider_snapshot',
],
[
'module_key' => 'fxratesapi',
'module_label' => 'FXRatesAPI',
'metric_key' => 'rate_fetch_calls',
'metric_label' => 'Currency rate fetches',
'unit' => 'calls',
'period' => 'day',
'source' => 'internal_counter',
'default_enforce_mode' => 'block',
'config_module' => 'fxratesapi',
'config_variable' => 'daily_limit',
'config_type' => 'int',
'legacy_count_table' => 'fxratesapi_conversion_rates',
'primary' => true,
'writable_limit' => true,
],
[
'module_key' => 'virkdata',
'module_label' => 'VirkData',
'metric_key' => 'company_search_calls',
'metric_label' => 'Company searches',
'unit' => 'calls',
'period' => 'month',
'source' => 'internal_counter',
'default_enforce_mode' => 'observe',
'config_module' => 'virkdata',
'config_variable' => 'monthly_limit',
'config_type' => 'int',
'legacy_log_module' => 'VIRKDATA',
'legacy_log_action' => 'VIRKDATA_SEARCH',
'primary' => true,
'writable_limit' => true,
],
[
'module_key' => 'licenseplaterecognizer',
'module_label' => 'License Plate Recognizer',
'metric_key' => 'plate_recognition_calls',
'metric_label' => 'Plate recognition calls',
'unit' => 'calls',
'period' => 'provider',
'source' => 'provider_snapshot',
'default_enforce_mode' => 'observe',
'primary' => true,
],
[
'module_key' => 'email',
'module_label' => 'Email',
'metric_key' => 'mailersend_messages',
'metric_label' => 'MailerSend messages',
'unit' => 'messages',
'period' => 'provider',
'source' => 'provider_snapshot',
],
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'api_calls', 'metric_label' => 'API calls', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'openai', 'module_label' => 'OpenAI', 'metric_key' => 'total_tokens', 'metric_label' => 'Total tokens', 'unit' => 'tokens', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'weatherapi', 'module_label' => 'WeatherAPI', 'metric_key' => 'requests', 'metric_label' => 'Weather requests', 'unit' => 'calls', 'period' => 'day', 'source' => 'internal_counter'],
['module_key' => 'gatewayapi', 'module_label' => 'GatewayAPI', 'metric_key' => 'sms_messages', 'metric_label' => 'SMS messages', 'unit' => 'messages', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'bird', 'module_label' => 'Bird', 'metric_key' => 'messages_and_calls', 'metric_label' => 'Messages and calls', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'ocrspace', 'module_label' => 'OCRSpace', 'metric_key' => 'ocr_requests', 'metric_label' => 'OCR requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'limble', 'module_label' => 'Limble', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'workfeed', 'module_label' => 'Workfeed', 'metric_key' => 'api_requests', 'metric_label' => 'API requests', 'unit' => 'calls', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'stripe', 'module_label' => 'Stripe', 'metric_key' => 'payment_events', 'metric_label' => 'Payment events', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'coolify', 'module_label' => 'Coolify', 'metric_key' => 'operations', 'metric_label' => 'Operations', 'unit' => 'events', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'backup_jobs', 'metric_label' => 'Backup jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'backups', 'module_label' => 'Backups', 'metric_key' => 'stored_bytes', 'metric_label' => 'Stored backup data', 'unit' => 'bytes', 'period' => 'all_time', 'source' => 'derived'],
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'wash_sessions', 'metric_label' => 'Wash sessions', 'unit' => 'sessions', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'selfserve', 'module_label' => 'Self Serve', 'metric_key' => 'lane_commands', 'metric_label' => 'Lane commands', 'unit' => 'events', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SELFSERVE'],
['module_key' => 'xlvask', 'module_label' => 'XLVask', 'metric_key' => 'usage_rows', 'metric_label' => 'Usage rows', 'unit' => 'rows', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'attachments', 'module_label' => 'Attachments', 'metric_key' => 'stored_files', 'metric_label' => 'Stored files', 'unit' => 'files', 'period' => 'all_time', 'source' => 'derived'],
['module_key' => 'dynamicimages', 'module_label' => 'Dynamic Images', 'metric_key' => 'renders', 'metric_label' => 'Image renders', 'unit' => 'renders', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'html2pdf', 'module_label' => 'HTML2PDF', 'metric_key' => 'pdf_jobs', 'metric_label' => 'PDF jobs', 'unit' => 'jobs', 'period' => 'month', 'source' => 'internal_counter'],
['module_key' => 'forms', 'module_label' => 'Forms', 'metric_key' => 'submissions', 'metric_label' => 'Submissions', 'unit' => 'submissions', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'notifications', 'module_label' => 'Notifications', 'metric_key' => 'notification_sends', 'metric_label' => 'Notification sends', 'unit' => 'notifications', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'shelly', 'module_label' => 'Shelly', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'internal_counter', 'legacy_log_module' => 'SHELLY'],
['module_key' => 'edgegateway', 'module_label' => 'Edge Gateway', 'metric_key' => 'relay_commands', 'metric_label' => 'Relay commands', 'unit' => 'commands', 'period' => 'month', 'source' => 'derived'],
['module_key' => 'system', 'module_label' => 'System', 'metric_key' => 'cron_runs', 'metric_label' => 'Cron runs', 'unit' => 'runs', 'period' => 'day', 'source' => 'derived'],
]
);
}
public function find(string $moduleKey, string $metricKey): ?array
{
$moduleKey = $this->normalizeModuleKey($moduleKey);
$metricKey = $this->normalizeMetricKey($metricKey);
foreach ($this->all() as $descriptor) {
if ($descriptor['module_key'] === $moduleKey && $descriptor['metric_key'] === $metricKey) {
return $descriptor;
}
}
return null;
}
public function forModule(string $moduleKey): array
{
$moduleKey = $this->normalizeModuleKey($moduleKey);
return array_values(array_filter(
$this->all(),
static fn(array $descriptor): bool => $descriptor['module_key'] === $moduleKey
));
}
public function normalizeModuleKey(string $moduleKey): string
{
return strtolower(trim($moduleKey));
}
public function normalizeMetricKey(string $metricKey): string
{
return strtolower(trim($metricKey));
}
private function withDefaults(array $descriptor): array
{
$descriptor['module_key'] = $this->normalizeModuleKey((string)$descriptor['module_key']);
$descriptor['metric_key'] = $this->normalizeMetricKey((string)$descriptor['metric_key']);
$descriptor['module_label'] = (string)($descriptor['module_label'] ?? $descriptor['module_key']);
$descriptor['metric_label'] = (string)($descriptor['metric_label'] ?? $descriptor['metric_key']);
$descriptor['unit'] = (string)($descriptor['unit'] ?? 'count');
$descriptor['period'] = (string)($descriptor['period'] ?? 'all_time');
$descriptor['scope_type'] = (string)($descriptor['scope_type'] ?? 'global');
$descriptor['scope_id'] = (string)($descriptor['scope_id'] ?? '');
$descriptor['source'] = (string)($descriptor['source'] ?? 'internal_counter');
$descriptor['default_enforce_mode'] = (string)($descriptor['default_enforce_mode'] ?? 'observe');
$descriptor['soft_limit_percent'] = (float)($descriptor['soft_limit_percent'] ?? self::DEFAULT_SOFT_LIMIT_PERCENT);
$descriptor['writable_limit'] = (bool)($descriptor['writable_limit'] ?? false);
$descriptor['primary'] = (bool)($descriptor['primary'] ?? false);
return $descriptor;
}
}
@@ -0,0 +1,119 @@
<?php
namespace classes;
class module_usage_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(
"CREATE TABLE IF NOT EXISTS module_usage_counters (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module_key VARCHAR(64) NOT NULL,
metric_key VARCHAR(128) NOT NULL,
scope_type VARCHAR(32) NOT NULL DEFAULT 'global',
scope_id VARCHAR(191) NOT NULL DEFAULT '',
period_key VARCHAR(32) NOT NULL DEFAULT 'all_time',
period_start DATETIME NOT NULL,
period_end DATETIME NULL,
unit VARCHAR(32) NOT NULL DEFAULT 'count',
used_quantity DECIMAL(20,4) NOT NULL DEFAULT 0,
limit_quantity DECIMAL(20,4) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'ok',
metadata_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_module_usage_counter (module_key, metric_key, scope_type, scope_id, period_key, period_start),
KEY idx_module_usage_counters_module_period (module_key, period_key, period_start),
KEY idx_module_usage_counters_status (status, updated_at),
KEY idx_module_usage_counters_scope (scope_type, scope_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS module_usage_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module_key VARCHAR(64) NOT NULL,
metric_key VARCHAR(128) NOT NULL,
source VARCHAR(32) NOT NULL DEFAULT 'provider',
period_key VARCHAR(32) NOT NULL DEFAULT 'provider',
period_start DATETIME NULL,
period_end DATETIME NULL,
unit VARCHAR(32) NOT NULL DEFAULT 'count',
used_quantity DECIMAL(20,4) NULL,
limit_quantity DECIMAL(20,4) NULL,
remaining_quantity DECIMAL(20,4) NULL,
usage_percent DECIMAL(8,4) NULL,
status VARCHAR(32) NOT NULL DEFAULT 'unknown',
raw_payload_json LONGTEXT NULL,
checked_at DATETIME NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_module_usage_snapshots_module_checked (module_key, metric_key, checked_at),
KEY idx_module_usage_snapshots_status (status, checked_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS module_quota_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module_key VARCHAR(64) NOT NULL,
metric_key VARCHAR(128) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
enforce_mode VARCHAR(16) NOT NULL DEFAULT 'observe',
soft_limit_percent DECIMAL(6,2) NOT NULL DEFAULT 90.00,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_module_quota_setting (module_key, metric_key),
KEY idx_module_quota_settings_mode (enforce_mode, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$db->query(
"CREATE TABLE IF NOT EXISTS module_usage_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
module VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
status_code INT NOT NULL DEFAULT 0,
data LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_module_usage_logs_module_created (module, created_at),
KEY idx_module_usage_logs_action_created (action, created_at),
KEY idx_module_usage_logs_status_created (status_code, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
self::ensureColumn('module_quota_settings', 'enabled', "TINYINT(1) NOT NULL DEFAULT 1 AFTER metric_key");
self::ensureColumn('module_quota_settings', 'soft_limit_percent', "DECIMAL(6,2) NOT NULL DEFAULT 90.00 AFTER enforce_mode");
self::$initialized = true;
}
private static function ensureColumn(string $table, string $column, string $definition): void
{
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return;
}
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result && $result->num_rows > 0) {
return;
}
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
@@ -0,0 +1,997 @@
<?php
namespace classes;
use Exception;
use mysqli_result;
use Throwable;
class module_usage_service
{
private module_usage_registry $registry;
public function __construct(?module_usage_registry $registry = null)
{
module_usage_schema_bootstrap::ensureTables();
$this->registry = $registry ?? new module_usage_registry();
}
public function summary(array $filters = []): array
{
$moduleFilter = isset($filters['module']) ? $this->registry->normalizeModuleKey((string)$filters['module']) : '';
$periodFilter = isset($filters['period']) ? strtolower(trim((string)$filters['period'])) : '';
$statusFilter = isset($filters['status']) ? strtolower(trim((string)$filters['status'])) : '';
$date = isset($filters['date']) ? (string)$filters['date'] : null;
$metrics = [];
foreach ($this->registry->all() as $descriptor) {
if ($moduleFilter !== '' && $descriptor['module_key'] !== $moduleFilter) {
continue;
}
if ($periodFilter !== '' && $periodFilter !== 'all' && $descriptor['period'] !== $periodFilter) {
continue;
}
$metric = $this->currentMetric($descriptor, $date);
if ($statusFilter !== '' && $metric['status'] !== $statusFilter) {
continue;
}
$metrics[] = $metric;
}
$modules = [];
foreach ($metrics as $metric) {
$moduleKey = $metric['module_key'];
if (!isset($modules[$moduleKey])) {
$modules[$moduleKey] = [
'key' => $moduleKey,
'label' => $metric['module_label'],
'status' => 'ok',
'metrics' => [],
];
}
$modules[$moduleKey]['metrics'][] = $metric;
$modules[$moduleKey]['status'] = $this->worseStatus($modules[$moduleKey]['status'], $metric['status']);
}
return [
'generated_at' => date('c'),
'filters' => [
'module' => $moduleFilter !== '' ? $moduleFilter : null,
'period' => $periodFilter !== '' ? $periodFilter : null,
'status' => $statusFilter !== '' ? $statusFilter : null,
'date' => $date,
],
'modules' => array_values($modules),
'metrics' => $metrics,
];
}
public function moduleDetail(string $moduleKey, array $filters = []): array
{
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
$descriptors = $this->registry->forModule($moduleKey);
$date = isset($filters['date']) ? (string)$filters['date'] : null;
$metrics = array_map(fn(array $descriptor): array => $this->currentMetric($descriptor, $date), $descriptors);
return [
'generated_at' => date('c'),
'module_key' => $moduleKey,
'metrics' => $metrics,
'history' => $this->historyForModule($moduleKey, $filters),
];
}
public function metricsForModule(string $moduleKey): array
{
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
return array_map(
fn(array $descriptor): array => $this->currentMetric($descriptor),
$this->registry->forModule($moduleKey)
);
}
/**
* Atomically records usage and enforces blocking quotas when the metric is configured for block mode.
*
* @throws Exception
*/
public function reserveOrFail(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$quantity = max(0.0, $quantity);
if ($quantity <= 0.0) {
return $this->currentMetric($descriptor);
}
$setting = $this->settingFor($descriptor);
if (($setting['enabled'] ?? true) !== true || ($setting['enforce_mode'] ?? 'observe') !== 'block') {
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
}
$limit = $this->resolveLimitQuantity($descriptor);
if ($limit === null) {
return $this->recordUsage($moduleKey, $metricKey, $quantity, $metadata);
}
$period = $this->periodWindow((string)$descriptor['period']);
$this->insertCounterIfMissing($descriptor, $period, $limit);
global $db;
$where = $this->counterWhereSql($descriptor, $period);
$quantitySql = $this->numberSql($quantity);
$limitSql = $this->numberSql($limit);
$metadataSql = $this->jsonSql($metadata);
$db->query(
"UPDATE module_usage_counters
SET used_quantity = used_quantity + {$quantitySql},
limit_quantity = {$limitSql},
metadata_json = {$metadataSql},
status = CASE
WHEN {$limitSql} <= 0 OR ((used_quantity + {$quantitySql}) >= {$limitSql}) THEN 'exhausted'
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= " . module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT . " THEN 'near_limit'
ELSE 'ok'
END
WHERE {$where} AND (used_quantity + {$quantitySql}) <= {$limitSql}"
);
if ((int)$db->conn()->affected_rows <= 0) {
throw new Exception($this->quotaExceededMessage($descriptor));
}
return $this->currentMetric($descriptor);
}
public function recordUsage(string $moduleKey, string $metricKey, float $quantity = 1.0, array $metadata = []): array
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$quantity = max(0.0, $quantity);
if ($quantity <= 0.0 || !$this->databaseReady()) {
return $this->currentMetric($descriptor);
}
$limit = $this->resolveLimitQuantity($descriptor);
$setting = $this->settingFor($descriptor);
$period = $this->periodWindow((string)$descriptor['period']);
$this->insertCounterIfMissing($descriptor, $period, $limit);
global $db;
$where = $this->counterWhereSql($descriptor, $period);
$quantitySql = $this->numberSql($quantity);
$limitSql = $this->nullableNumberSql($limit);
$metadataSql = $this->jsonSql($metadata);
$softLimitSql = $this->numberSql((float)$setting['soft_limit_percent']);
$statusSql = (($setting['enabled'] ?? true) !== true)
? $this->sqlString('disabled')
: "CASE
WHEN {$limitSql} IS NULL THEN 'unlimited'
WHEN {$limitSql} <= 0 AND (used_quantity + {$quantitySql}) > 0 THEN 'exhausted'
WHEN {$limitSql} <= 0 THEN 'ok'
WHEN (used_quantity + {$quantitySql}) >= {$limitSql} THEN 'exhausted'
WHEN ((used_quantity + {$quantitySql}) / {$limitSql}) * 100 >= {$softLimitSql} THEN 'near_limit'
ELSE 'ok'
END";
$db->query(
"UPDATE module_usage_counters
SET used_quantity = used_quantity + {$quantitySql},
limit_quantity = {$limitSql},
metadata_json = {$metadataSql},
status = {$statusSql}
WHERE {$where}"
);
return $this->currentMetric($descriptor);
}
public function currentUsedQuantity(string $moduleKey, string $metricKey): int
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$metric = $this->currentMetric($descriptor);
return (int)floor((float)($metric['used'] ?? 0));
}
public function updateQuotaSetting(string $moduleKey, string $metricKey, array $payload): array
{
$descriptor = $this->requireDescriptor($moduleKey, $metricKey);
$setting = $this->settingFor($descriptor);
$enabled = array_key_exists('enabled', $payload) ? $this->toBool($payload['enabled']) : (bool)$setting['enabled'];
$enforceMode = array_key_exists('enforce_mode', $payload) ? strtolower(trim((string)$payload['enforce_mode'])) : (string)$setting['enforce_mode'];
if (!in_array($enforceMode, ['observe', 'block'], true)) {
throw new Exception('Invalid enforce mode.');
}
$softLimitPercent = array_key_exists('soft_limit_percent', $payload)
? (float)$payload['soft_limit_percent']
: (float)$setting['soft_limit_percent'];
if ($softLimitPercent < 1.0 || $softLimitPercent > 100.0) {
throw new Exception('Soft limit percent must be between 1 and 100.');
}
if (array_key_exists('limit', $payload) || array_key_exists('hard_limit', $payload) || array_key_exists('hard_limit_quantity', $payload)) {
if (empty($descriptor['writable_limit']) || empty($descriptor['config_module']) || empty($descriptor['config_variable'])) {
throw new Exception('quota_not_writable');
}
$limitValue = $payload['limit'] ?? $payload['hard_limit'] ?? $payload['hard_limit_quantity'];
if (!is_numeric($limitValue) || (int)$limitValue < 0) {
throw new Exception('Limit must be a non-negative integer.');
}
$this->writeConfigLimit($descriptor, (int)$limitValue);
}
if ($this->databaseReady()) {
global $db;
$moduleKeySql = $this->sqlString((string)$descriptor['module_key']);
$metricKeySql = $this->sqlString((string)$descriptor['metric_key']);
$enabledSql = $enabled ? '1' : '0';
$modeSql = $this->sqlString($enforceMode);
$softLimitSql = $this->numberSql($softLimitPercent);
$db->query(
"INSERT INTO module_quota_settings (module_key, metric_key, enabled, enforce_mode, soft_limit_percent)
VALUES ({$moduleKeySql}, {$metricKeySql}, {$enabledSql}, {$modeSql}, {$softLimitSql})
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
enforce_mode = VALUES(enforce_mode),
soft_limit_percent = VALUES(soft_limit_percent)"
);
}
return $this->currentMetric($descriptor);
}
public function recordProviderSnapshotFromLegacyUsage(string $moduleKey, array $usage, array $rawPayload = []): ?array
{
$moduleKey = $this->registry->normalizeModuleKey($moduleKey);
$descriptor = null;
foreach ($this->registry->forModule($moduleKey) as $candidate) {
if (($candidate['source'] ?? '') === 'provider_snapshot') {
$descriptor = $candidate;
break;
}
}
if ($descriptor === null) {
return null;
}
$used = $this->firstNumeric($usage, ['used', 'calls_used', 'messages_used']);
$limit = $this->firstNumeric($usage, ['limit', 'quota', 'quota_calls', 'total_calls']);
$remaining = $this->firstNumeric($usage, ['remaining', 'calls_remaining', 'messages_remaining']);
$percent = $this->firstNumeric($usage, ['usage_percent', 'percent']);
$usageAvailable = !array_key_exists('usage_available', $usage) || $this->toBool($usage['usage_available']);
$unavailableReason = trim((string)($usage['unavailable_reason'] ?? ''));
if ($used === null && $limit === null && $usageAvailable && $unavailableReason === '') {
return null;
}
if ($remaining === null && $used !== null && $limit !== null) {
$remaining = max(0.0, $limit - $used);
}
if ($percent === null && $used !== null && $limit !== null && $limit > 0) {
$percent = round(($used / $limit) * 100, 4);
}
$status = (!$usageAvailable || $unavailableReason !== '')
? 'unknown'
: $this->statusForUsage($used, $limit, $this->settingFor($descriptor));
$payload = array_merge($rawPayload, ['usage' => $this->redactPayload($usage)]);
if ($this->databaseReady()) {
try {
global $db;
$db->query(
"INSERT INTO module_usage_snapshots
(module_key, metric_key, source, period_key, unit, used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at)
VALUES (
" . $this->sqlString((string)$descriptor['module_key']) . ",
" . $this->sqlString((string)$descriptor['metric_key']) . ",
'provider',
'provider',
" . $this->sqlString((string)$descriptor['unit']) . ",
" . $this->nullableNumberSql($used) . ",
" . $this->nullableNumberSql($limit) . ",
" . $this->nullableNumberSql($remaining) . ",
" . $this->nullableNumberSql($percent) . ",
" . $this->sqlString($status) . ",
" . $this->jsonSql($payload) . ",
" . $this->sqlString(date('Y-m-d H:i:s')) . "
)"
);
} catch (Throwable) {
// Provider snapshots are observability data. They must never break probes.
}
}
return $this->providerMetricFromValues($descriptor, $used, $limit, $remaining, $percent, $status, date('c'), $usage);
}
public function primarySystemUsage(array $metrics): ?array
{
$metrics = array_values(array_filter(
$metrics,
static fn(array $metric): bool => ($metric['limit'] ?? null) !== null || ($metric['used'] ?? null) !== null
));
if ($metrics === []) {
return null;
}
usort($metrics, function (array $left, array $right): int {
if (($left['primary'] ?? false) !== ($right['primary'] ?? false)) {
return ($right['primary'] ?? false) <=> ($left['primary'] ?? false);
}
$leftRank = $this->statusRank((string)($left['status'] ?? 'unknown'));
$rightRank = $this->statusRank((string)($right['status'] ?? 'unknown'));
return $rightRank <=> $leftRank;
});
$metric = $metrics[0];
return [
'provider' => $metric['module_key'],
'metric_key' => $metric['metric_key'],
'unit' => $metric['unit'],
'period' => $metric['period'],
'calls_used' => $metric['used'],
'quota_calls' => $metric['limit'],
'calls_remaining' => $metric['remaining'],
'usage_percent' => $metric['usage_percent'],
'status' => $metric['status'],
'source' => $metric['source'],
'enforce_mode' => $metric['enforce_mode'],
];
}
public function currentMetric(array $descriptor, ?string $date = null): array
{
$descriptor = $this->normalizeDescriptor($descriptor);
$setting = $this->settingFor($descriptor);
$window = $this->periodWindow((string)$descriptor['period'], $date);
$limit = $this->resolveLimitQuantity($descriptor);
$used = null;
$updatedAt = null;
$historyAvailable = false;
$snapshotExtra = [];
if (($descriptor['source'] ?? '') === 'provider_snapshot') {
$snapshot = $this->latestProviderSnapshot($descriptor);
if ($snapshot !== null) {
$used = $snapshot['used_quantity'];
$limit = $snapshot['limit_quantity'];
$updatedAt = $snapshot['checked_at'];
$snapshotExtra = $snapshot['extra'];
$historyAvailable = true;
}
} else {
$counter = $this->counterFor($descriptor, $window);
if ($counter !== null) {
$used = $counter['used_quantity'];
$limit = $counter['limit_quantity'] ?? $limit;
$updatedAt = $counter['updated_at'] ?? $counter['created_at'] ?? null;
$historyAvailable = true;
} else {
$derived = $this->derivedOrLegacyUsage($descriptor, $window);
if ($derived !== null) {
$used = $derived;
$historyAvailable = true;
} elseif (($descriptor['source'] ?? '') === 'internal_counter') {
$used = 0.0;
$historyAvailable = true;
}
}
}
$status = $this->statusForUsage($used, $limit, $setting);
$remaining = ($used !== null && $limit !== null) ? max(0.0, $limit - $used) : null;
$usagePercent = ($used !== null && $limit !== null && $limit > 0) ? round(($used / $limit) * 100, 2) : null;
return array_merge([
'module_key' => $descriptor['module_key'],
'module_label' => $descriptor['module_label'],
'metric_key' => $descriptor['metric_key'],
'metric_label' => $descriptor['metric_label'],
'unit' => $descriptor['unit'],
'period' => $descriptor['period'],
'scope_type' => $descriptor['scope_type'],
'scope_id' => $descriptor['scope_id'],
'source' => $descriptor['source'],
'primary' => (bool)$descriptor['primary'],
'writable_limit' => (bool)$descriptor['writable_limit'],
'limit_source' => isset($descriptor['config_variable']) ? 'module_config' : (($descriptor['source'] ?? '') === 'provider_snapshot' ? 'provider' : null),
'config_module' => $descriptor['config_module'] ?? null,
'config_variable' => $descriptor['config_variable'] ?? null,
'used' => $used,
'limit' => $limit,
'remaining' => $remaining,
'usage_percent' => $usagePercent,
'status' => $status,
'enabled' => (bool)$setting['enabled'],
'enforce_mode' => $setting['enforce_mode'],
'soft_limit_percent' => (float)$setting['soft_limit_percent'],
'window' => [
'start' => $window['start_c'],
'end' => $window['end_c'],
'timezone' => date_default_timezone_get(),
],
'updated_at' => $updatedAt,
'history_available' => $historyAvailable,
], $snapshotExtra);
}
private function requireDescriptor(string $moduleKey, string $metricKey): array
{
$descriptor = $this->registry->find($moduleKey, $metricKey);
if ($descriptor === null) {
throw new Exception('Unknown module usage metric.');
}
return $descriptor;
}
private function normalizeDescriptor(array $descriptor): array
{
$found = $this->registry->find((string)$descriptor['module_key'], (string)$descriptor['metric_key']);
return $found ?? $descriptor;
}
private function settingFor(array $descriptor): array
{
$default = [
'enabled' => true,
'enforce_mode' => (string)($descriptor['default_enforce_mode'] ?? 'observe'),
'soft_limit_percent' => (float)($descriptor['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT),
];
if (!$this->databaseReady()) {
return $default;
}
try {
global $db;
$result = $db->query(
"SELECT enabled, enforce_mode, soft_limit_percent
FROM module_quota_settings
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return $default;
}
return [
'enabled' => (bool)((int)($row['enabled'] ?? 1)),
'enforce_mode' => in_array((string)($row['enforce_mode'] ?? ''), ['observe', 'block'], true)
? (string)$row['enforce_mode']
: $default['enforce_mode'],
'soft_limit_percent' => is_numeric($row['soft_limit_percent'] ?? null)
? (float)$row['soft_limit_percent']
: $default['soft_limit_percent'],
];
} catch (Throwable) {
return $default;
}
}
private function resolveLimitQuantity(array $descriptor): ?float
{
if (empty($descriptor['config_module']) || empty($descriptor['config_variable']) || !$this->databaseReady()) {
return null;
}
try {
global $db;
$result = $db->query(
"SELECT value
FROM module_config
WHERE module = " . $this->sqlString((string)$descriptor['config_module']) . "
AND variable = " . $this->sqlString((string)$descriptor['config_variable']) . "
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row) || !is_numeric($row['value'] ?? null)) {
return null;
}
return max(0.0, (float)$row['value']);
} catch (Throwable) {
return null;
}
}
private function writeConfigLimit(array $descriptor, int $limit): void
{
if (!$this->databaseReady()) {
return;
}
global $db;
$module = (string)$descriptor['config_module'];
$variable = (string)$descriptor['config_variable'];
$type = (string)($descriptor['config_type'] ?? 'int');
$existing = $db->query(
"SELECT id
FROM module_config
WHERE module = " . $this->sqlString($module) . "
AND variable = " . $this->sqlString($variable) . "
LIMIT 1"
);
if ($existing instanceof mysqli_result && $existing->num_rows > 0) {
$db->query(
"UPDATE module_config
SET value = " . $this->sqlString((string)$limit) . ", type = " . $this->sqlString($type) . "
WHERE module = " . $this->sqlString($module) . "
AND variable = " . $this->sqlString($variable)
);
} else {
$db->query(
"INSERT INTO module_config (module, variable, value, type)
VALUES (" . $this->sqlString($module) . ", " . $this->sqlString($variable) . ", " . $this->sqlString((string)$limit) . ", " . $this->sqlString($type) . ")"
);
}
system_search_cache::markDirtyTable('module_config');
}
private function insertCounterIfMissing(array $descriptor, array $period, ?float $limit): void
{
if (!$this->databaseReady()) {
return;
}
global $db;
$baseline = $this->derivedOrLegacyUsage($descriptor, $period);
$baseline = $baseline === null ? 0.0 : max(0.0, (float)$baseline);
$metadata = [
'created_from' => $baseline > 0 ? 'legacy_or_derived_baseline' : 'counter',
];
$db->query(
"INSERT IGNORE INTO module_usage_counters
(module_key, metric_key, scope_type, scope_id, period_key, period_start, period_end, unit, used_quantity, limit_quantity, status, metadata_json)
VALUES (
" . $this->sqlString((string)$descriptor['module_key']) . ",
" . $this->sqlString((string)$descriptor['metric_key']) . ",
" . $this->sqlString((string)$descriptor['scope_type']) . ",
" . $this->sqlString((string)$descriptor['scope_id']) . ",
" . $this->sqlString($period['key']) . ",
" . $this->sqlString($period['start_sql']) . ",
" . ($period['end_sql'] === null ? 'NULL' : $this->sqlString($period['end_sql'])) . ",
" . $this->sqlString((string)$descriptor['unit']) . ",
" . $this->numberSql($baseline) . ",
" . $this->nullableNumberSql($limit) . ",
" . $this->sqlString($this->statusForUsage($baseline, $limit, $this->settingFor($descriptor))) . ",
" . $this->jsonSql($metadata) . "
)"
);
}
private function counterFor(array $descriptor, array $period): ?array
{
if (!$this->databaseReady()) {
return null;
}
try {
global $db;
$result = $db->query(
"SELECT used_quantity, limit_quantity, status, created_at, updated_at
FROM module_usage_counters
WHERE " . $this->counterWhereSql($descriptor, $period) . "
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return null;
}
return [
'used_quantity' => (float)$row['used_quantity'],
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
'status' => (string)$row['status'],
'created_at' => $row['created_at'] ?? null,
'updated_at' => $row['updated_at'] ?? null,
];
} catch (Throwable) {
return null;
}
}
private function latestProviderSnapshot(array $descriptor): ?array
{
if (!$this->databaseReady()) {
return null;
}
try {
global $db;
$result = $db->query(
"SELECT used_quantity, limit_quantity, remaining_quantity, usage_percent, status, raw_payload_json, checked_at
FROM module_usage_snapshots
WHERE module_key = " . $this->sqlString((string)$descriptor['module_key']) . "
AND metric_key = " . $this->sqlString((string)$descriptor['metric_key']) . "
ORDER BY checked_at DESC, id DESC
LIMIT 1"
);
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
if (!is_array($row)) {
return null;
}
$raw = json_decode((string)($row['raw_payload_json'] ?? ''), true);
$usage = is_array($raw) && isset($raw['usage']) && is_array($raw['usage']) ? $raw['usage'] : [];
$extra = [];
if (isset($usage['version'])) {
$extra['version'] = (string)$usage['version'];
}
if (array_key_exists('usage_available', $usage)) {
$extra['usage_available'] = $this->toBool($usage['usage_available']);
}
if (isset($usage['unavailable_reason'])) {
$extra['unavailable_reason'] = (string)$usage['unavailable_reason'];
}
if (isset($usage['detected_keys']) && is_array($usage['detected_keys'])) {
$extra['detected_keys'] = array_values(array_map('strval', $usage['detected_keys']));
}
return [
'used_quantity' => $row['used_quantity'] === null ? null : (float)$row['used_quantity'],
'limit_quantity' => $row['limit_quantity'] === null ? null : (float)$row['limit_quantity'],
'remaining_quantity' => $row['remaining_quantity'] === null ? null : (float)$row['remaining_quantity'],
'usage_percent' => $row['usage_percent'] === null ? null : (float)$row['usage_percent'],
'status' => (string)$row['status'],
'checked_at' => $row['checked_at'] ? date('c', strtotime((string)$row['checked_at'])) : null,
'extra' => $extra,
];
} catch (Throwable) {
return null;
}
}
private function providerMetricFromValues(array $descriptor, ?float $used, ?float $limit, ?float $remaining, ?float $percent, string $status, string $checkedAt, array $usage): array
{
return [
'module_key' => $descriptor['module_key'],
'module_label' => $descriptor['module_label'],
'metric_key' => $descriptor['metric_key'],
'metric_label' => $descriptor['metric_label'],
'unit' => $descriptor['unit'],
'period' => $descriptor['period'],
'scope_type' => $descriptor['scope_type'],
'scope_id' => $descriptor['scope_id'],
'source' => $descriptor['source'],
'primary' => (bool)$descriptor['primary'],
'writable_limit' => false,
'limit_source' => 'provider',
'used' => $used,
'limit' => $limit,
'remaining' => $remaining,
'usage_percent' => $percent,
'status' => $status,
'enabled' => true,
'enforce_mode' => 'observe',
'soft_limit_percent' => module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT,
'window' => ['start' => null, 'end' => null, 'timezone' => date_default_timezone_get()],
'updated_at' => $checkedAt,
'history_available' => true,
'version' => isset($usage['version']) ? (string)$usage['version'] : null,
'usage_available' => array_key_exists('usage_available', $usage) ? $this->toBool($usage['usage_available']) : true,
'unavailable_reason' => isset($usage['unavailable_reason']) ? (string)$usage['unavailable_reason'] : null,
'detected_keys' => isset($usage['detected_keys']) && is_array($usage['detected_keys'])
? array_values(array_map('strval', $usage['detected_keys']))
: [],
];
}
private function derivedOrLegacyUsage(array $descriptor, array $period): ?float
{
try {
if (isset($descriptor['legacy_count_table'])) {
return $this->countRowsInPeriod((string)$descriptor['legacy_count_table'], 'created_at', $period);
}
if (isset($descriptor['legacy_log_module'])) {
return $this->countActionLogs(
(string)$descriptor['legacy_log_module'],
isset($descriptor['legacy_log_action']) ? (string)$descriptor['legacy_log_action'] : null,
$period
);
}
return match ($descriptor['module_key'] . '.' . $descriptor['metric_key']) {
'backups.backup_jobs' => $this->countRowsInPeriod('backup_jobs', 'created_at', $period),
'backups.stored_bytes' => $this->sumColumn('backup_records', 'total_bytes'),
'coolify.operations' => $this->countRowsInPeriod('coolify_operations', 'created_at', $period),
'selfserve.wash_sessions' => $this->countRowsInPeriod('selfserve_wash_sessions', 'created_at', $period),
'xlvask.usage_rows' => $this->countRowsInPeriod('xlvask_usage_logs', 'StartTime', $period),
'attachments.stored_files' => $this->countRowsInPeriod('object_attachments', null, $period),
'forms.submissions' => $this->countRowsInPeriod('form_submissions', 'created_at', $period),
'notifications.notification_sends' => $this->countRowsInPeriod('notifications', 'created_at', $period),
'edgegateway.relay_commands' => $this->countRowsInPeriod('edge_gateway_operations', 'created_at', $period),
'system.cron_runs' => $this->countRowsInPeriod('cron_task_runs', 'created_at', $period),
default => null,
};
} catch (Throwable) {
return null;
}
}
private function countActionLogs(string $module, ?string $action, array $period): ?float
{
if (!$this->tableExists('module_usage_logs')) {
return null;
}
global $db;
$where = "UPPER(module) = " . $this->sqlString(strtoupper($module));
if ($action !== null && $action !== '') {
$where .= " AND UPPER(action) = " . $this->sqlString(strtoupper($action));
}
$where .= $this->periodWhereSql('created_at', $period);
$result = $db->query("SELECT COUNT(*) AS usage_count FROM module_usage_logs WHERE {$where}");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
return is_array($row) ? (float)$row['usage_count'] : null;
}
private function countRowsInPeriod(string $table, ?string $dateColumn, array $period): ?float
{
if (!$this->tableExists($table)) {
return null;
}
if ($dateColumn !== null && !$this->columnExists($table, $dateColumn)) {
return null;
}
global $db;
$where = '1=1';
if ($dateColumn !== null) {
$where .= $this->periodWhereSql($dateColumn, $period);
} elseif ($period['key'] !== 'all_time') {
return null;
}
$result = $db->query("SELECT COUNT(*) AS usage_count FROM `{$table}` WHERE {$where}");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
return is_array($row) ? (float)$row['usage_count'] : null;
}
private function sumColumn(string $table, string $column): ?float
{
if (!$this->tableExists($table) || !$this->columnExists($table, $column)) {
return null;
}
global $db;
$result = $db->query("SELECT COALESCE(SUM(`{$column}`), 0) AS usage_sum FROM `{$table}`");
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
return is_array($row) ? (float)$row['usage_sum'] : null;
}
private function historyForModule(string $moduleKey, array $filters): array
{
if (!$this->databaseReady()) {
return [];
}
$limit = isset($filters['limit']) && is_numeric($filters['limit']) ? max(1, min(200, (int)$filters['limit'])) : 100;
$rows = [];
try {
global $db;
$result = $db->query(
"SELECT module_key, metric_key, period_key, period_start, period_end, used_quantity, limit_quantity, status, updated_at, created_at
FROM module_usage_counters
WHERE module_key = " . $this->sqlString($moduleKey) . "
ORDER BY period_start DESC, id DESC
LIMIT {$limit}"
);
if ($result instanceof mysqli_result) {
while ($row = $result->fetch_assoc()) {
$rows[] = $row;
}
}
} catch (Throwable) {
return [];
}
return $rows;
}
private function statusForUsage(?float $used, ?float $limit, array $setting): string
{
if (($setting['enabled'] ?? true) !== true) {
return 'disabled';
}
if ($used === null) {
return 'unknown';
}
if ($limit === null) {
return 'unlimited';
}
if ($limit <= 0.0) {
return $used > 0.0 ? 'exhausted' : 'ok';
}
$percent = ($used / $limit) * 100;
if ($used >= $limit || $percent >= 100.0) {
return 'exhausted';
}
if ($percent >= (float)($setting['soft_limit_percent'] ?? module_usage_registry::DEFAULT_SOFT_LIMIT_PERCENT)) {
return 'near_limit';
}
return 'ok';
}
private function worseStatus(string $current, string $candidate): string
{
return $this->statusRank($candidate) > $this->statusRank($current) ? $candidate : $current;
}
private function statusRank(string $status): int
{
return match ($status) {
'exhausted' => 5,
'near_limit' => 4,
'unknown' => 3,
'disabled' => 2,
'unlimited' => 1,
'ok' => 0,
default => 0,
};
}
private function quotaExceededMessage(array $descriptor): string
{
return match ((string)$descriptor['period']) {
'day' => 'Daily limit exceeded',
'month' => 'Monthly limit exceeded',
default => 'Quota limit exceeded',
};
}
private function periodWindow(string $period, ?string $date = null): array
{
$timestamp = $date ? strtotime($date) : time();
if ($timestamp === false) {
$timestamp = time();
}
return match ($period) {
'day' => $this->periodFromTimestamps('day', strtotime(date('Y-m-d 00:00:00', $timestamp)), strtotime(date('Y-m-d 00:00:00', $timestamp) . ' +1 day')),
'month' => $this->periodFromTimestamps('month', strtotime(date('Y-m-01 00:00:00', $timestamp)), strtotime(date('Y-m-01 00:00:00', $timestamp) . ' +1 month')),
'provider' => ['key' => 'provider', 'start_sql' => date('Y-m-d 00:00:00', $timestamp), 'end_sql' => null, 'start_c' => null, 'end_c' => null],
default => ['key' => 'all_time', 'start_sql' => '1970-01-01 00:00:00', 'end_sql' => null, 'start_c' => null, 'end_c' => null],
};
}
private function periodFromTimestamps(string $key, int $start, int $end): array
{
return [
'key' => $key,
'start_sql' => date('Y-m-d H:i:s', $start),
'end_sql' => date('Y-m-d H:i:s', $end),
'start_c' => date('c', $start),
'end_c' => date('c', $end),
];
}
private function periodWhereSql(string $dateColumn, array $period): string
{
if ($period['key'] === 'all_time' || $period['key'] === 'provider') {
return '';
}
$dateColumn = preg_replace('/[^a-zA-Z0-9_]/', '', $dateColumn);
if ($dateColumn === '') {
return '';
}
return " AND `{$dateColumn}` >= " . $this->sqlString($period['start_sql']) . " AND `{$dateColumn}` < " . $this->sqlString((string)$period['end_sql']);
}
private function counterWhereSql(array $descriptor, array $period): string
{
return "module_key = " . $this->sqlString((string)$descriptor['module_key'])
. " AND metric_key = " . $this->sqlString((string)$descriptor['metric_key'])
. " AND scope_type = " . $this->sqlString((string)$descriptor['scope_type'])
. " AND scope_id = " . $this->sqlString((string)$descriptor['scope_id'])
. " AND period_key = " . $this->sqlString($period['key'])
. " AND period_start = " . $this->sqlString($period['start_sql']);
}
private function databaseReady(): bool
{
global $db;
return isset($db) && is_object($db) && method_exists($db, 'query') && method_exists($db, 'conn');
}
private function tableExists(string $table): bool
{
if (!$this->databaseReady()) {
return false;
}
try {
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
if ($table === '') {
return false;
}
$result = $db->query("SHOW TABLES LIKE " . $this->sqlString($table));
return $result instanceof mysqli_result && $result->num_rows > 0;
} catch (Throwable) {
return false;
}
}
private function columnExists(string $table, string $column): bool
{
if (!$this->databaseReady()) {
return false;
}
try {
global $db;
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
$column = preg_replace('/[^a-zA-Z0-9_]/', '', $column);
if ($table === '' || $column === '') {
return false;
}
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE " . $this->sqlString($column));
return $result instanceof mysqli_result && $result->num_rows > 0;
} catch (Throwable) {
return false;
}
}
private function firstNumeric(array $payload, array $keys): ?float
{
foreach ($keys as $key) {
if (isset($payload[$key]) && is_numeric($payload[$key])) {
return (float)$payload[$key];
}
}
return null;
}
private function redactPayload(array $payload): array
{
$redacted = [];
foreach ($payload as $key => $value) {
$normalized = strtolower((string)$key);
if (str_contains($normalized, 'key') || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) {
$redacted[$key] = '[redacted]';
continue;
}
$redacted[$key] = is_array($value) ? $this->redactPayload($value) : $value;
}
return $redacted;
}
private function toBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
}
private function sqlString(string $value): string
{
global $db;
return "'" . $db->escape_string($value) . "'";
}
private function numberSql(float $value): string
{
return rtrim(rtrim(sprintf('%.4F', $value), '0'), '.') ?: '0';
}
private function nullableNumberSql(?float $value): string
{
return $value === null ? 'NULL' : $this->numberSql($value);
}
private function jsonSql(array $value): string
{
return $this->sqlString(json_encode($this->redactPayload($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}
+21 -3
View File
@@ -13,6 +13,7 @@ use motorapi\actions\license_plate_lookup_a;
use motorapi\helpers\motorapi_vehicle_types; use motorapi\helpers\motorapi_vehicle_types;
use motorapi\motorapi_c; use motorapi\motorapi_c;
use objects\motorapi_lookups_o; use objects\motorapi_lookups_o;
use Throwable;
class motorapi implements motorapi_i class motorapi implements motorapi_i
{ {
@@ -182,10 +183,10 @@ class motorapi implements motorapi_i
self::requireModuleEnabled(); self::requireModuleEnabled();
// Validate the license plate // Validate the license plate
self::requireValidLicensePlate($licensePlate); self::requireValidLicensePlate($licensePlate);
// Validate the daily limit
self::requireDailyLimitNotExceeded();
// Validate the secret key // Validate the secret key
self::requireValidSecretKey(); self::requireValidSecretKey();
// Reserve quota for the outbound provider call. Cache hits return before this point.
$this->reserveLookupQuota($licensePlate, $endpoint, $method);
// Send the request // Send the request
$response = match ($method) { $response = match ($method) {
'GET' => self::sendGetRequest($licensePlate, $endpoint, $data), 'GET' => self::sendGetRequest($licensePlate, $endpoint, $data),
@@ -217,7 +218,7 @@ class motorapi implements motorapi_i
function requireDailyLimitNotExceeded(): void function requireDailyLimitNotExceeded(): void
{ {
// Check if the daily limit is exceeded // Check if the daily limit is exceeded
if ($this->getDailyRequestCounter() >= $this->config->daily_limit->getVariableValue()) { if ($this->getDailyRequestCounter() >= (int)$this->config->daily_limit->getVariableValue()) {
throw new Exception('Daily limit exceeded'); throw new Exception('Daily limit exceeded');
} }
} }
@@ -227,12 +228,29 @@ class motorapi implements motorapi_i
*/ */
function getDailyRequestCounter(): int function getDailyRequestCounter(): int
{ {
try {
return (new module_usage_service())->currentUsedQuantity('motorapi', 'lookup_calls');
} catch (Throwable) {
}
// Count the rows from the motorapi request log that was made today // Count the rows from the motorapi request log that was made today
$motorapi_lookups = new motorapi_lookups_o(); $motorapi_lookups = new motorapi_lookups_o();
$motorapi_lookups->getTodayCount(); $motorapi_lookups->getTodayCount();
return $motorapi_lookups->getTodayCount(); return $motorapi_lookups->getTodayCount();
} }
/**
* @throws Exception
*/
private function reserveLookupQuota(string $licensePlate, string $endpoint, string $method): void
{
(new module_usage_service())->reserveOrFail('motorapi', 'lookup_calls', 1, [
'license_plate' => $licensePlate,
'endpoint' => $endpoint,
'method' => strtoupper($method),
]);
}
/** /**
* @inheritDoc * @inheritDoc
*/ */
+602 -16
View File
@@ -37,6 +37,8 @@ class release_manager
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api'; private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
private const CRON_WORKER_APP = 'cron'; private const CRON_WORKER_APP = 'cron';
private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'; private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker';
private const CRON_WORKER_DESIRED_COUNT = 1;
private const CRON_WORKER_HEARTBEAT_GRACE_SECONDS = 180;
private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io']; private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io'];
private const RELEASE_GATE_MAX_PATHS = 10; private const RELEASE_GATE_MAX_PATHS = 10;
private const RELEASE_GATE_MAX_ASSETS = 50; private const RELEASE_GATE_MAX_ASSETS = 50;
@@ -4413,16 +4415,65 @@ class release_manager
{ {
$this->ensureSchema(); $this->ensureSchema();
$channel = $this->channelFromInputOrDefault($input); $channel = $this->channelFromInputOrDefault($input);
$target = $this->cronWorkerTargetForChannel((int)$channel['id']); $channelId = (int)$channel['id'];
$apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api');
$target = $this->cronWorkerTargetForChannel($channelId);
$publicTarget = $target !== null ? $this->publicDeploymentTarget($target) : null;
$publicApiTarget = $apiTarget !== null ? $this->publicDeploymentTarget($apiTarget) : null;
$workerRows = (new cron_worker())->listWorkers();
$workers = $this->cronWorkersForTarget(
is_array($workerRows['workers'] ?? null) ? $workerRows['workers'] : [],
$channelId,
$target
);
$summary = $this->cronWorkerSummary($workers);
$recentDeployments = $this->cronWorkerDeployments($channelId);
$latestDeployment = $recentDeployments[0] ?? null;
$providerStatus = $this->cronWorkerProviderStatus($target, $input);
$health = $this->cronWorkerHealth($apiTarget, $target, $workers, $summary, $latestDeployment);
$readiness = $this->cronWorkerDeploymentReadiness($apiTarget, $target, $providerStatus);
$issues = $this->cronWorkerMergeIssues(
is_array($health['issues'] ?? null) ? $health['issues'] : [],
is_array($readiness['issues'] ?? null) ? $readiness['issues'] : []
);
$channelPayload = [
'id' => $channelId,
'slug' => (string)($channel['slug'] ?? ''),
'name' => (string)($channel['name'] ?? ''),
];
$deploymentPayload = [
'ok' => true,
'state' => $health['state'],
'desired_workers' => self::CRON_WORKER_DESIRED_COUNT,
'channel' => $channelPayload,
'api_target' => $publicApiTarget,
'target' => $publicTarget,
'latest_deployment' => $latestDeployment,
'provider' => $providerStatus,
'action' => $readiness['action'],
'can_deploy' => $readiness['can_deploy'],
'issues' => $issues,
];
return [ return [
'ok' => true, 'ok' => true,
'channel' => [ 'state' => $health['state'],
'id' => (int)$channel['id'], 'desired_workers' => self::CRON_WORKER_DESIRED_COUNT,
'slug' => (string)($channel['slug'] ?? ''), 'channel' => $channelPayload,
'name' => (string)($channel['name'] ?? ''), 'channels' => $this->cronWorkerChannels(),
'api_target' => $publicApiTarget,
'cron_target' => $publicTarget,
'target' => $publicTarget,
'workers' => $workers,
'summary' => $summary + [
'desired' => self::CRON_WORKER_DESIRED_COUNT,
'state' => $health['state'],
], ],
'target' => $target !== null ? $this->publicDeploymentTarget($target) : null, 'latest_deployment' => $latestDeployment,
'recent_deployments' => $recentDeployments,
'provider' => $providerStatus,
'issues' => $issues,
'deployment' => $deploymentPayload,
]; ];
} }
@@ -4431,6 +4482,8 @@ class release_manager
$this->ensureSchema(); $this->ensureSchema();
$dryRun = $this->toBool($input['dry_run'] ?? false); $dryRun = $this->toBool($input['dry_run'] ?? false);
$apiTarget = null; $apiTarget = null;
$channel = null;
$existingCronTarget = null;
$targetId = $this->nullablePositiveInt($input['api_target_id'] ?? $input['target_id'] ?? null); $targetId = $this->nullablePositiveInt($input['api_target_id'] ?? $input['target_id'] ?? null);
if ($targetId !== null) { if ($targetId !== null) {
@@ -4440,7 +4493,12 @@ class release_manager
} }
} else { } else {
$channel = $this->channelFromInputOrDefault($input); $channel = $this->channelFromInputOrDefault($input);
$apiTarget = $this->deploymentTargetFromInput(['channel_id' => (int)$channel['id']], (int)$channel['id'], 'api'); $channelId = (int)$channel['id'];
$existingCronTarget = $this->cronWorkerTargetForChannel($channelId);
$apiTarget = $this->deploymentTargetFromInput(['channel_id' => $channelId], $channelId, 'api');
if ($apiTarget === null && $this->cronWorkerTargetCanDeployWithoutApiTarget($existingCronTarget)) {
$apiTarget = $this->cronWorkerSourceFromCronTarget($existingCronTarget, $channel);
}
} }
if ($apiTarget === null) { if ($apiTarget === null) {
@@ -4451,6 +4509,16 @@ class release_manager
return $this->deployCronWorkerForApiTarget($apiTarget, $commitSha !== '' ? $commitSha : null, $actorUserId, $dryRun); return $this->deployCronWorkerForApiTarget($apiTarget, $commitSha !== '' ? $commitSha : null, $actorUserId, $dryRun);
} }
private function cronWorkerSourceFromCronTarget(array $target, ?array $channel = null): array
{
return array_replace($target, [
'app' => self::CRON_WORKER_APP,
'channel_id' => (int)($target['channel_id'] ?? $channel['id'] ?? 0),
'channel_slug' => (string)($target['channel_slug'] ?? $channel['slug'] ?? ''),
'channel_name' => (string)($target['channel_name'] ?? $channel['name'] ?? ''),
]);
}
private function deployCronWorkerForApiTarget( private function deployCronWorkerForApiTarget(
array $apiTarget, array $apiTarget,
?string $commitSha = null, ?string $commitSha = null,
@@ -4479,19 +4547,41 @@ class release_manager
$repository = trim((string)($apiTarget['repository'] ?? '')); $repository = trim((string)($apiTarget['repository'] ?? ''));
} }
$branch = trim((string)($apiTarget['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH; $branch = trim((string)($apiTarget['branch'] ?? self::DEFAULT_BRANCH)) ?: self::DEFAULT_BRANCH;
$serviceUuid = trim((string)($existing['coolify_service_uuid'] ?? '')) ?: null; $serviceUuid = trim((string)($existing['coolify_service_uuid'] ?? ''));
$providerStatus = $existing !== null
? $this->cronWorkerProviderStatus($existing, ['include_provider' => true])
: ['configured' => false, 'missing' => false];
$repairMissingResource = $existing !== null
&& $serviceUuid !== ''
&& $this->toBool($providerStatus['missing'] ?? false);
$sourceApp = (string)($apiTarget['app'] ?? 'api');
$sourceTargetId = (int)($apiTarget['id'] ?? 0);
if ($repairMissingResource) {
$context['cron_worker_orphaned_coolify_service_uuid'] = $serviceUuid;
$context['cron_worker_orphaned_at'] = date('Y-m-d H:i:s');
$context['cron_worker_repair_reason'] = 'coolify_resource_missing';
$serviceUuid = '';
}
$planAction = $targetId > 0
? ($repairMissingResource ? 'repair' : ($serviceUuid === '' ? 'create' : 'update'))
: 'create';
$plan = [ $plan = [
'type' => 'deploy_cron_worker', 'type' => 'deploy_cron_worker',
'channel_id' => $channelId, 'channel_id' => $channelId,
'channel_slug' => (string)($channel['slug'] ?? ''), 'channel_slug' => (string)($channel['slug'] ?? ''),
'api_target_id' => (int)($apiTarget['id'] ?? 0), 'api_target_id' => $sourceApp === 'api' && $sourceTargetId > 0 ? $sourceTargetId : null,
'source_target_id' => $sourceTargetId > 0 ? $sourceTargetId : null,
'source_app' => $sourceApp,
'target_id' => $targetId > 0 ? $targetId : null, 'target_id' => $targetId > 0 ? $targetId : null,
'action' => $targetId > 0 ? 'update' : 'create', 'action' => $planAction,
'repository' => $repository, 'repository' => $repository,
'branch' => $branch, 'branch' => $branch,
'commit_sha' => $commitSha, 'commit_sha' => $commitSha,
'coolify_instance_id' => (int)$apiTarget['coolify_instance_id'], 'coolify_instance_id' => (int)$apiTarget['coolify_instance_id'],
'coolify_service_uuid' => $serviceUuid, 'coolify_service_uuid' => $serviceUuid !== '' ? $serviceUuid : null,
'orphaned_coolify_service_uuid' => $repairMissingResource
? (string)($existing['coolify_service_uuid'] ?? '')
: null,
'start_command' => self::CRON_WORKER_START_COMMAND, 'start_command' => self::CRON_WORKER_START_COMMAND,
'dry_run' => $dryRun, 'dry_run' => $dryRun,
]; ];
@@ -4503,25 +4593,38 @@ class release_manager
'mutated' => false, 'mutated' => false,
'planned' => [$plan], 'planned' => [$plan],
'target' => $existing !== null ? $this->publicDeploymentTarget($existing) : null, 'target' => $existing !== null ? $this->publicDeploymentTarget($existing) : null,
'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]),
]; ];
} }
$cronDeploymentId = $this->createCronWorkerDeploymentRecord(
$channelId,
$targetId > 0 ? $targetId : null,
$repository,
$branch,
$commitSha,
$actorUserId,
['plan' => $plan, 'parent_deployment_id' => $parentDeploymentId]
);
try {
if ($targetId > 0) { if ($targetId > 0) {
$this->execute( $this->execute(
"UPDATE release_deployment_targets "UPDATE release_deployment_targets
SET coolify_instance_id = ?, repository = ?, branch = ?, auto_deploy = 0, SET coolify_instance_id = ?, coolify_service_uuid = ?, repository = ?, branch = ?, auto_deploy = 0,
health_url = NULL, deploy_context_json = ? health_url = NULL, deploy_context_json = ?
WHERE id = ? AND deleted_at IS NULL", WHERE id = ? AND deleted_at IS NULL",
'isssi', 'issssi',
[ [
(int)$apiTarget['coolify_instance_id'], (int)$apiTarget['coolify_instance_id'],
$serviceUuid !== '' ? $serviceUuid : null,
$repository, $repository,
$branch, $branch,
self::jsonEncode($context), self::jsonEncode($context),
$targetId, $targetId,
] ]
); );
$action = 'cron_worker_target_updated'; $action = $repairMissingResource ? 'cron_worker_target_repaired' : 'cron_worker_target_updated';
} else { } else {
$this->execute( $this->execute(
"INSERT INTO release_deployment_targets ( "INSERT INTO release_deployment_targets (
@@ -4533,7 +4636,7 @@ class release_manager
$channelId, $channelId,
self::CRON_WORKER_APP, self::CRON_WORKER_APP,
(int)$apiTarget['coolify_instance_id'], (int)$apiTarget['coolify_instance_id'],
$serviceUuid, $serviceUuid !== '' ? $serviceUuid : null,
$repository, $repository,
$branch, $branch,
self::jsonEncode($context), self::jsonEncode($context),
@@ -4546,6 +4649,7 @@ class release_manager
'si', 'si',
[self::jsonEncode($context), $targetId] [self::jsonEncode($context), $targetId]
); );
$this->execute('UPDATE release_deployments SET target_id = ? WHERE id = ?', 'ii', [$targetId, $cronDeploymentId]);
$action = 'cron_worker_target_created'; $action = 'cron_worker_target_created';
} }
@@ -4556,11 +4660,20 @@ class release_manager
'commit_sha' => $commitSha ?? '', 'commit_sha' => $commitSha ?? '',
]); ]);
$deployment = $this->deployCoolifyReleaseTarget($deployTarget); $deployment = $this->deployCoolifyReleaseTarget($deployTarget);
$providerOperationId = $this->coolifyDeploymentOperationId($deployment);
$this->execute(
"UPDATE release_deployments
SET status = 'deployed', provider_operation_id = ?, result_json = ?, completed_at = NOW()
WHERE id = ?",
'ssi',
[$providerOperationId, self::jsonEncode(self::redactPayload($deployment)), $cronDeploymentId]
);
$this->audit($channelId, $parentDeploymentId, $action, $actorUserId, 'info', [ $this->audit($channelId, $cronDeploymentId, $action, $actorUserId, 'info', [
'api_target_id' => (int)($apiTarget['id'] ?? 0), 'api_target_id' => (int)($apiTarget['id'] ?? 0),
'cron_target_id' => $targetId, 'cron_target_id' => $targetId,
'commit_sha' => $commitSha, 'commit_sha' => $commitSha,
'parent_deployment_id' => $parentDeploymentId,
'deployment' => $deployment, 'deployment' => $deployment,
]); ]);
@@ -4571,11 +4684,46 @@ class release_manager
'planned' => [$plan], 'planned' => [$plan],
'applied' => [[ 'applied' => [[
'target_id' => $targetId, 'target_id' => $targetId,
'deployment_id' => $cronDeploymentId,
'action' => $action, 'action' => $action,
'deployment' => $deployment, 'deployment' => $deployment,
]], ]],
'deployment' => $this->publicDeployment($this->getDeployment($cronDeploymentId)),
'target' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)), 'target' => $this->publicDeploymentTarget($this->getDeploymentTarget($targetId)),
'worker_status' => $this->cronWorkerStatus(['channel_id' => $channelId]),
]; ];
} catch (Throwable $throwable) {
$this->execute(
"UPDATE release_deployments
SET status = 'failed', result_json = ?, error_message = ?, completed_at = NOW()
WHERE id = ?",
'ssi',
[
self::jsonEncode([
'message' => 'Cron worker deployment failed before a worker heartbeat was observed.',
'failure_summary' => self::deploymentFailureSummary($throwable, [
'app' => self::CRON_WORKER_APP,
'repository' => $repository,
'branch' => $branch,
'commit_sha' => $commitSha,
'target_id' => $targetId > 0 ? $targetId : null,
'coolify_instance_id' => (int)($apiTarget['coolify_instance_id'] ?? 0),
'coolify_service_uuid' => $serviceUuid,
]),
]),
$throwable->getMessage(),
$cronDeploymentId,
]
);
$this->audit($channelId, $cronDeploymentId, 'cron_worker_deploy_failed', $actorUserId, 'warning', [
'api_target_id' => (int)($apiTarget['id'] ?? 0),
'cron_target_id' => $targetId > 0 ? $targetId : null,
'commit_sha' => $commitSha,
'parent_deployment_id' => $parentDeploymentId,
'error' => $throwable->getMessage(),
]);
throw $throwable;
}
} }
private function cronWorkerTargetForChannel(int $channelId): ?array private function cronWorkerTargetForChannel(int $channelId): ?array
@@ -4593,6 +4741,430 @@ class release_manager
); );
} }
private function createCronWorkerDeploymentRecord(
int $channelId,
?int $targetId,
string $repository,
string $branch,
?string $commitSha,
?int $actorUserId,
array $requestedPayload
): int {
$this->execute(
"INSERT INTO release_deployments (
channel_id, target_id, deployment_kind, app, provider, repository, branch,
commit_sha, status, actor_user_id, requested_payload_json, started_at
) VALUES (?, ?, 'cron_worker', ?, 'coolify', ?, ?, ?, 'deploying', ?, ?, NOW())",
'iissssis',
[
$channelId,
$targetId,
self::CRON_WORKER_APP,
$repository,
$branch,
$commitSha,
$actorUserId,
self::jsonEncode(self::redactPayload($requestedPayload)),
]
);
return $this->insertId();
}
private function cronWorkerDeployments(int $channelId, int $limit = 5): array
{
$limit = max(1, min(25, $limit));
return array_map(
fn(array $deployment): array => $this->publicDeployment($deployment),
$this->selectRows(
"SELECT d.*, c.slug AS channel_slug, c.name AS channel_name, v.version_label, v.deployed_url
FROM release_deployments d
INNER JOIN release_channels c ON c.id = d.channel_id
LEFT JOIN release_versions v ON v.id = d.version_id
WHERE d.channel_id = ? AND d.app = ? AND d.deployment_kind = 'cron_worker'
ORDER BY d.id DESC
LIMIT $limit",
'is',
[$channelId, self::CRON_WORKER_APP]
)
);
}
private function cronWorkerChannels(): array
{
return array_map(
static fn(array $channel): array => [
'id' => (int)($channel['id'] ?? 0),
'slug' => (string)($channel['slug'] ?? ''),
'name' => (string)($channel['name'] ?? ''),
'default_channel' => (bool)((int)($channel['default_channel'] ?? 0)),
],
$this->selectRows(
"SELECT id, slug, name, default_channel
FROM release_channels
WHERE deleted_at IS NULL AND enabled = 1
ORDER BY default_channel DESC, slug"
)
);
}
private function cronWorkersForTarget(array $workers, int $channelId, ?array $target): array
{
$targetId = $target !== null ? (int)($target['id'] ?? 0) : 0;
$resourceUuid = $target !== null ? trim((string)($target['coolify_service_uuid'] ?? '')) : '';
$matched = [];
foreach ($workers as $worker) {
if (!is_array($worker)) {
continue;
}
$workerChannelId = (int)($worker['release_channel_id'] ?? 0);
$workerTargetId = (int)($worker['release_target_id'] ?? 0);
$workerResourceUuid = trim((string)($worker['coolify_resource_uuid'] ?? ''));
if (
($workerChannelId > 0 && $workerChannelId === $channelId)
|| ($targetId > 0 && $workerTargetId === $targetId)
|| ($resourceUuid !== '' && $workerResourceUuid === $resourceUuid)
) {
$matched[] = $worker;
}
}
return $matched;
}
private function cronWorkerSummary(array $workers): array
{
$running = 0;
$stale = 0;
$failed = 0;
foreach ($workers as $worker) {
$status = (string)($worker['status'] ?? '');
$isStale = (bool)($worker['stale'] ?? false);
if ($status === 'running' && !$isStale) {
$running++;
}
if ($isStale) {
$stale++;
}
if ($status === 'failed') {
$failed++;
}
}
return [
'total' => count($workers),
'running' => $running,
'stale' => $stale,
'failed' => $failed,
];
}
private function cronWorkerHealth(
?array $apiTarget,
?array $target,
array $workers,
array $summary,
?array $latestDeployment
): array {
$issues = [];
if ($apiTarget === null) {
$issues[] = $this->cronWorkerIssue('missing_api_target', 'danger', 'No API deployment target is configured for this release channel.');
return ['state' => 'needs_deploy', 'issues' => $issues];
}
if ($target === null) {
$issues[] = $this->cronWorkerIssue('missing_cron_target', 'warning', 'No cron worker Coolify target exists for this release channel.');
return ['state' => 'needs_deploy', 'issues' => $issues];
}
$deploymentStatus = (string)($latestDeployment['status'] ?? '');
if ($deploymentStatus === 'failed') {
$issues[] = $this->cronWorkerIssue(
'latest_deployment_failed',
'danger',
(string)($latestDeployment['error_message'] ?? 'Latest cron worker deployment failed.')
);
return ['state' => 'failed', 'issues' => $issues];
}
if (in_array($deploymentStatus, ['queued', 'deploying'], true)) {
$issues[] = $this->cronWorkerIssue('deployment_in_progress', 'info', 'Cron worker deployment is in progress.');
return ['state' => 'deploying', 'issues' => $issues];
}
$running = (int)($summary['running'] ?? 0);
$stale = (int)($summary['stale'] ?? 0);
$failed = (int)($summary['failed'] ?? 0);
if ($running >= self::CRON_WORKER_DESIRED_COUNT && $stale === 0 && $failed === 0) {
return ['state' => 'healthy', 'issues' => []];
}
if ($running > 0) {
if ($stale > 0) {
$issues[] = $this->cronWorkerIssue('stale_workers_present', 'warning', 'At least one cron worker heartbeat is stale.');
}
if ($failed > 0) {
$issues[] = $this->cronWorkerIssue('failed_workers_present', 'warning', 'At least one cron worker reported a failed loop.');
}
if ($running < self::CRON_WORKER_DESIRED_COUNT) {
$issues[] = $this->cronWorkerIssue('below_desired_worker_count', 'warning', 'Fewer cron workers are running than desired.');
}
return ['state' => 'degraded', 'issues' => $issues];
}
if ($workers === []) {
$age = $this->cronWorkerDeploymentAgeSeconds($latestDeployment);
if ($deploymentStatus === 'deployed' && ($age === null || $age <= self::CRON_WORKER_HEARTBEAT_GRACE_SECONDS)) {
$issues[] = $this->cronWorkerIssue('waiting_for_first_heartbeat', 'info', 'Coolify accepted the deployment; waiting for the worker to write its first heartbeat.');
return ['state' => 'waiting_for_heartbeat', 'issues' => $issues];
}
$issues[] = $this->cronWorkerIssue('no_worker_heartbeat', 'danger', 'Cron worker target exists, but no worker heartbeat has been recorded.');
return ['state' => $deploymentStatus === 'deployed' ? 'failed' : 'degraded', 'issues' => $issues];
}
if ($stale > 0 || $failed > 0) {
$issues[] = $this->cronWorkerIssue('no_fresh_running_worker', 'danger', 'Cron workers exist, but none have a fresh running heartbeat.');
return ['state' => 'failed', 'issues' => $issues];
}
$issues[] = $this->cronWorkerIssue('worker_not_running', 'warning', 'Cron worker is not currently running.');
return ['state' => 'degraded', 'issues' => $issues];
}
private function cronWorkerDeploymentReadiness(?array $apiTarget, ?array $target, array $providerStatus): array
{
$issues = [];
if ($target === null) {
if ($apiTarget === null) {
$issues[] = $this->cronWorkerIssue(
'missing_api_target',
'danger',
'No API deployment target is configured for this release channel.'
);
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
}
return ['action' => 'create', 'can_deploy' => true, 'issues' => []];
}
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
$canUseCronTargetContext = $this->cronWorkerTargetCanDeployWithoutApiTarget($target);
if ($resourceUuid === '') {
if ($apiTarget !== null || $canUseCronTargetContext) {
if ($apiTarget === null) {
$issues[] = $this->cronWorkerIssue(
'repairable_cron_target',
'info',
'No API target is configured, but the cron target has enough deployment context to deploy a worker.'
);
}
return ['action' => 'create', 'can_deploy' => true, 'issues' => $issues];
}
$issues[] = $this->cronWorkerIssue(
'missing_api_target',
'danger',
'No API deployment target is configured for this release channel.'
);
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
}
if ($this->toBool($providerStatus['missing'] ?? false)) {
$issues[] = $this->cronWorkerIssue(
'missing_coolify_worker_resource',
'danger',
'The stored Coolify cron worker resource was not found and must be recreated.'
);
if ($apiTarget !== null || $canUseCronTargetContext) {
if ($apiTarget === null) {
$issues[] = $this->cronWorkerIssue(
'repairable_cron_target',
'info',
'No API target is configured, but the cron target has enough deployment context to repair itself.'
);
}
return ['action' => 'repair', 'can_deploy' => true, 'issues' => $issues];
}
$issues[] = $this->cronWorkerIssue(
'missing_api_target',
'danger',
'No API deployment target is configured for this release channel.'
);
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
}
if ($apiTarget === null && !$canUseCronTargetContext) {
$issues[] = $this->cronWorkerIssue(
'missing_api_target',
'danger',
'No API deployment target is configured for this release channel.'
);
return ['action' => 'blocked', 'can_deploy' => false, 'issues' => $issues];
}
return ['action' => 'update', 'can_deploy' => true, 'issues' => $issues];
}
private function cronWorkerTargetCanDeployWithoutApiTarget(?array $target): bool
{
if ($target === null) {
return false;
}
$repository = self::normalizeGithubRepositoryName((string)($target['repository'] ?? ''));
if ($repository === '') {
$repository = trim((string)($target['repository'] ?? ''));
}
return $this->nullablePositiveInt($target['coolify_instance_id'] ?? null) !== null
&& $repository !== '';
}
private function cronWorkerMergeIssues(array ...$issueGroups): array
{
$merged = [];
$seen = [];
foreach ($issueGroups as $issues) {
foreach ($issues as $issue) {
if (!is_array($issue)) {
continue;
}
$key = trim((string)($issue['code'] ?? ''));
if ($key === '') {
$key = trim((string)($issue['message'] ?? ''));
}
if ($key !== '' && isset($seen[$key])) {
continue;
}
if ($key !== '') {
$seen[$key] = true;
}
$merged[] = $issue;
}
}
return $merged;
}
private function cronWorkerIssue(string $code, string $severity, string $message): array
{
return [
'code' => $code,
'severity' => $severity,
'message' => $message,
];
}
private function cronWorkerDeploymentAgeSeconds(?array $deployment): ?int
{
if ($deployment === null) {
return null;
}
foreach (['completed_at', 'started_at', 'created_at'] as $key) {
$value = trim((string)($deployment[$key] ?? ''));
if ($value === '') {
continue;
}
$timestamp = strtotime($value);
if ($timestamp !== false) {
return max(0, time() - $timestamp);
}
}
return null;
}
private function cronWorkerProviderStatus(?array $target, array $input): array
{
if ($target === null) {
return [
'configured' => false,
'resource' => null,
];
}
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
$resourceType = trim((string)($context['coolify_resource_type'] ?? 'application')) ?: 'application';
$status = [
'configured' => $resourceUuid !== '',
'instance_id' => isset($target['coolify_instance_id']) ? (int)$target['coolify_instance_id'] : null,
'instance_label' => $target['coolify_instance_label'] ?? null,
'resource_uuid' => $resourceUuid !== '' ? $resourceUuid : null,
'resource_type' => $resourceType,
'resource' => null,
'checked' => false,
'missing' => false,
];
if (!$this->toBool($input['include_provider'] ?? $input['include_provider_status'] ?? false) || $resourceUuid === '') {
return $status;
}
try {
$status['checked'] = true;
$instance = $this->selectOne(
'SELECT * FROM coolify_instances WHERE id = ? AND deleted_at IS NULL',
'i',
[(int)($target['coolify_instance_id'] ?? 0)]
);
if ($instance === null) {
throw new RuntimeException('Coolify instance for cron worker target was not found.');
}
$client = $this->coolifyClientForInstance($instance, 3);
$resource = $resourceType === 'service'
? $client->getService($resourceUuid)
: $client->getApplication($resourceUuid);
$status['resource'] = [
'ok' => true,
'uuid' => $resource['uuid'] ?? $resourceUuid,
'name' => $resource['name'] ?? null,
'status' => $resource['status'] ?? $resource['state'] ?? null,
'fqdn' => $resource['fqdn'] ?? $resource['domains'] ?? null,
];
} catch (Throwable $throwable) {
$status['checked'] = true;
$status['missing'] = self::coolifyResourceMissing($throwable);
$status['resource'] = [
'ok' => false,
'error' => $throwable->getMessage(),
'missing' => $status['missing'],
];
}
return $status;
}
private static function coolifyResourceMissing(Throwable $throwable): bool
{
$message = strtolower($throwable->getMessage());
return str_contains($message, 'http 404')
|| str_contains($message, 'not found')
|| preg_match('/\b404\b/', $message) === 1;
}
private function coolifyDeploymentOperationId(array $deployment): ?string
{
foreach ([$deployment, $deployment['deployment'] ?? null, $deployment['data'] ?? null] as $candidate) {
if (!is_array($candidate)) {
continue;
}
foreach (['deployment_uuid', 'operation_id', 'deployment_id', 'uuid', 'id'] as $key) {
$value = trim((string)($candidate[$key] ?? ''));
if ($value !== '') {
return substr($value, 0, 128);
}
}
}
return null;
}
private function cronWorkerDeployContext(array $apiTarget, ?array $existing, ?string $commitSha, int $targetId): array private function cronWorkerDeployContext(array $apiTarget, ?array $existing, ?string $commitSha, int $targetId): array
{ {
$apiContext = self::jsonDecode($apiTarget['deploy_context_json'] ?? null); $apiContext = self::jsonDecode($apiTarget['deploy_context_json'] ?? null);
@@ -4632,7 +5204,13 @@ class release_manager
$context['coolify_is_auto_deploy_enabled'] = false; $context['coolify_is_auto_deploy_enabled'] = false;
$context['coolify_service_name'] = $workerName; $context['coolify_service_name'] = $workerName;
$context['cron_worker_autoprovision'] = true; $context['cron_worker_autoprovision'] = true;
if ((string)($apiTarget['app'] ?? 'api') === 'api') {
$context['cron_worker_source_api_target_id'] = (int)($apiTarget['id'] ?? 0); $context['cron_worker_source_api_target_id'] = (int)($apiTarget['id'] ?? 0);
unset($context['cron_worker_source_cron_target_id']);
} else {
$context['cron_worker_source_cron_target_id'] = (int)($apiTarget['id'] ?? 0);
unset($context['cron_worker_source_api_target_id']);
}
unset( unset(
$context['coolify_domain'], $context['coolify_domain'],
$context['coolify_public_url'], $context['coolify_public_url'],
@@ -7415,6 +7993,7 @@ class release_manager
throw new RuntimeException('Coolify did not return a resource UUID for the release target.'); throw new RuntimeException('Coolify did not return a resource UUID for the release target.');
} }
$context['coolify_resource_type'] = $resourceType; $context['coolify_resource_type'] = $resourceType;
$target['coolify_service_uuid'] = $serviceUuid;
$this->execute( $this->execute(
'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?', 'UPDATE release_deployment_targets SET coolify_service_uuid = ?, deploy_context_json = ? WHERE id = ?',
'ssi', 'ssi',
@@ -7636,6 +8215,12 @@ class release_manager
$env = array_replace($env, $contextEnv); $env = array_replace($env, $contextEnv);
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true'; $env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? '')); $env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
if ($app === self::CRON_WORKER_APP) {
$resourceUuid = trim((string)($target['coolify_service_uuid'] ?? ''));
if ($resourceUuid !== '') {
$env['CRON_WORKER_COOLIFY_RESOURCE_UUID'] = $resourceUuid;
}
}
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha); $this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
return $this->normalizeCoolifyRuntimeEnv($env); return $this->normalizeCoolifyRuntimeEnv($env);
} }
@@ -11577,6 +12162,7 @@ class release_manager
'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null, 'active_channel_app_key' => $deployment['active_channel_app_key'] ?? null,
'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '', 'active_current' => trim((string)($deployment['active_channel_app_key'] ?? '')) !== '',
'provider' => (string)($deployment['provider'] ?? 'coolify'), 'provider' => (string)($deployment['provider'] ?? 'coolify'),
'provider_operation_id' => $deployment['provider_operation_id'] ?? null,
'repository' => $deployment['repository'] ?? null, 'repository' => $deployment['repository'] ?? null,
'branch' => $deployment['branch'] ?? null, 'branch' => $deployment['branch'] ?? null,
'commit_sha' => $deployment['commit_sha'] ?? null, 'commit_sha' => $deployment['commit_sha'] ?? null,
@@ -537,6 +537,7 @@ class superuser_system_status_service
$result['configured'] = false; $result['configured'] = false;
$result['status'] = 'disabled'; $result['status'] = 'disabled';
$result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.')); $result = array_merge($result, $this->moduleReason('module_disabled', [], 'Module is disabled.'));
$result = $this->attachModuleUsageMetrics($result);
$results[] = $result; $results[] = $result;
continue; continue;
} }
@@ -548,6 +549,7 @@ class superuser_system_status_service
$result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params']) $result['status_reason_params'] = isset($configuration['reason_params']) && is_array($configuration['reason_params'])
? $configuration['reason_params'] ? $configuration['reason_params']
: ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired]; : ['variables' => implode(', ', $missingRequired), 'variables_list' => $missingRequired];
$result = $this->attachModuleUsageMetrics($result);
$results[] = $result; $results[] = $result;
continue; continue;
} }
@@ -563,6 +565,7 @@ class superuser_system_status_service
'Configuration is present, but no safe read-only probe is available.' 'Configuration is present, but no safe read-only probe is available.'
) )
); );
$result = $this->attachModuleUsageMetrics($result);
$results[] = $result; $results[] = $result;
continue; continue;
} }
@@ -578,6 +581,7 @@ class superuser_system_status_service
if (isset($probeResult['usage']) && is_array($probeResult['usage'])) { if (isset($probeResult['usage']) && is_array($probeResult['usage'])) {
$result['usage'] = $probeResult['usage']; $result['usage'] = $probeResult['usage'];
} }
$result = $this->attachModuleUsageMetrics($result, isset($probeResult['usage']) && is_array($probeResult['usage']) ? $probeResult['usage'] : null);
$results[] = $result; $results[] = $result;
} }
@@ -596,6 +600,60 @@ class superuser_system_status_service
return $results; return $results;
} }
protected function attachModuleUsageMetrics(array $moduleResult, ?array $probeUsage = null): array
{
try {
$service = new module_usage_service();
$metrics = $service->metricsForModule((string)($moduleResult['key'] ?? ''));
if ($probeUsage !== null) {
$probeMetric = $service->recordProviderSnapshotFromLegacyUsage(
(string)($moduleResult['key'] ?? ''),
$probeUsage,
['source' => 'system_status_probe']
);
if ($probeMetric !== null) {
$metrics = $this->replaceModuleUsageMetric($metrics, $probeMetric);
}
}
if ($metrics !== []) {
$moduleResult['usage_metrics'] = $metrics;
if (!isset($moduleResult['usage'])) {
$primaryUsage = $service->primarySystemUsage($metrics);
if ($primaryUsage !== null) {
$moduleResult['usage'] = $primaryUsage;
}
}
}
} catch (Throwable $throwable) {
$moduleResult['usage_metrics_error'] = $throwable->getMessage();
}
return $moduleResult;
}
protected function replaceModuleUsageMetric(array $metrics, array $replacement): array
{
$replaced = false;
foreach ($metrics as $index => $metric) {
if (
($metric['module_key'] ?? null) === ($replacement['module_key'] ?? null)
&& ($metric['metric_key'] ?? null) === ($replacement['metric_key'] ?? null)
) {
$metrics[$index] = $replacement;
$replaced = true;
break;
}
}
if (!$replaced) {
$metrics[] = $replacement;
}
return $metrics;
}
protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array protected function resolveModuleProbeResult(array $descriptor, array $moduleConfig, bool $force, array &$warnings): array
{ {
$cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key']; $cacheKey = self::MODULE_PROBE_CACHE_KEY_PREFIX . $descriptor['key'];
@@ -956,7 +1014,11 @@ class superuser_system_status_service
'Authorization: Bearer ' . $apiKey, 'Authorization: Bearer ' . $apiKey,
'Accept: application/json', 'Accept: application/json',
], ],
'MailerSend API' 'MailerSend API',
null,
'GET',
null,
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'email')
); );
} }
@@ -1033,7 +1095,11 @@ class superuser_system_status_service
return $this->performHttpProbe( return $this->performHttpProbe(
$this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'), $this->buildUrlWithQuery('https://v1.motorapi.dk', '/usage'),
['X-AUTH-TOKEN: ' . $secretKey], ['X-AUTH-TOKEN: ' . $secretKey],
'MotorAPI' 'MotorAPI',
null,
'GET',
null,
fn(array $httpResponse, string $label): array => $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, 'motorapi')
); );
} }
@@ -1502,30 +1568,31 @@ class superuser_system_status_service
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true); $decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
if (!is_array($decoded)) { if (!is_array($decoded)) {
return [ return $this->providerQuotaUnavailableProbeResult(
'status' => 'degraded', $httpResponse,
'status_reason' => $label . ' returned an unreadable usage payload.', $label,
'status_reason_key' => 'licenseplaterecognizer_usage_unreadable', 'licenseplaterecognizer',
'status_reason_params' => ['label' => $label], 'unreadable_payload',
'checked_at' => $httpResponse['checked_at'] ?? date('c'), []
'latency_ms' => $httpResponse['latency_ms'] ?? null, );
'http_status' => $httpResponse['http_status'] ?? null,
];
} }
$usage = is_array($decoded['usage'] ?? null) ? $decoded['usage'] : []; $usage = is_array($decoded['usage'] ?? null) ? $decoded['usage'] : [];
$callsUsedRaw = $usage['calls'] ?? null; $callsUsedRaw = $usage['calls'] ?? null;
$quotaCallsRaw = $decoded['total_calls'] ?? null; $quotaCallsRaw = $decoded['total_calls'] ?? null;
if (!is_numeric($callsUsedRaw) || !is_numeric($quotaCallsRaw) || (int)$quotaCallsRaw <= 0) { if (!is_numeric($callsUsedRaw) || !is_numeric($quotaCallsRaw) || (int)$quotaCallsRaw <= 0) {
return [ $reason = !is_numeric($quotaCallsRaw) ? 'missing_limit' : 'missing_usage';
'status' => 'degraded', if (is_numeric($quotaCallsRaw) && (int)$quotaCallsRaw <= 0) {
'status_reason' => $label . ' did not return usable quota values.', $reason = 'invalid_limit';
'status_reason_key' => 'licenseplaterecognizer_usage_missing', }
'status_reason_params' => ['label' => $label],
'checked_at' => $httpResponse['checked_at'] ?? date('c'), return $this->providerQuotaUnavailableProbeResult(
'latency_ms' => $httpResponse['latency_ms'] ?? null, $httpResponse,
'http_status' => $httpResponse['http_status'] ?? null, $label,
]; 'licenseplaterecognizer',
$reason,
$this->payloadKeyPaths($decoded)
);
} }
$callsUsed = max(0, (int)$callsUsedRaw); $callsUsed = max(0, (int)$callsUsedRaw);
@@ -1586,6 +1653,176 @@ class superuser_system_status_service
]; ];
} }
protected function evaluateProviderQuotaProbeResponse(array $httpResponse, string $label, string $moduleKey): array
{
$classified = $this->classifyHttpProbeResult($httpResponse, $label);
if (($classified['status'] ?? 'down') !== 'ok') {
return $classified;
}
$decoded = json_decode((string)($httpResponse['body'] ?? ''), true);
if (!is_array($decoded)) {
return $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
$moduleKey,
'unreadable_payload',
[]
);
}
$used = $this->findFirstNumericPayloadValue($decoded, ['used', 'usage', 'calls', 'messages_used', 'used_messages', 'sent', 'total_used']);
$limit = $this->findFirstNumericPayloadValue($decoded, ['limit', 'quota', 'total', 'total_calls', 'messages_limit', 'max', 'allowed']);
$remaining = $this->findFirstNumericPayloadValue($decoded, ['remaining', 'left', 'available', 'calls_remaining', 'messages_remaining']);
if ($used === null && $limit !== null && $remaining !== null) {
$used = max(0.0, $limit - $remaining);
}
if ($remaining === null && $used !== null && $limit !== null) {
$remaining = max(0.0, $limit - $used);
}
if ($limit === null) {
return $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
$moduleKey,
'missing_limit',
$this->payloadKeyPaths($decoded)
);
}
if ($limit <= 0) {
return $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
$moduleKey,
'invalid_limit',
$this->payloadKeyPaths($decoded)
);
}
if ($used === null) {
return $this->providerQuotaUnavailableProbeResult(
$httpResponse,
$label,
$moduleKey,
'missing_usage',
$this->payloadKeyPaths($decoded)
);
}
$usagePercent = round(($used / $limit) * 100, 2);
$usagePayload = [
'provider' => $moduleKey,
'calls_used' => $used,
'quota_calls' => $limit,
'calls_remaining' => $remaining,
'usage_percent' => $usagePercent,
];
if (($remaining !== null && $remaining <= 0) || $usagePercent >= 100.0) {
return [
'status' => 'down',
'status_reason' => $label . ' quota is exhausted.',
'status_reason_key' => 'provider_quota_exhausted',
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
if ($usagePercent >= 90.0) {
return [
'status' => 'degraded',
'status_reason' => $label . ' quota usage is near the limit.',
'status_reason_key' => 'provider_quota_near_limit',
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
return [
'status' => 'ok',
'status_reason' => $label . ' quota usage is available.',
'status_reason_key' => 'provider_quota_available',
'status_reason_params' => ['label' => $label, 'used' => (string)$used, 'quota' => (string)$limit, 'percent' => number_format($usagePercent, 1, '.', '')],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
protected function providerQuotaUnavailableProbeResult(array $httpResponse, string $label, string $moduleKey, string $reason, array $detectedKeys = []): array
{
$usagePayload = [
'provider' => $moduleKey,
'usage_available' => false,
'status' => 'unknown',
'calls_used' => null,
'quota_calls' => null,
'calls_remaining' => null,
'usage_percent' => null,
'unavailable_reason' => $reason,
'detected_keys' => $detectedKeys,
];
return [
'status' => 'degraded',
'status_reason' => $label . ' responded, but quota usage could not be read.',
'status_reason_key' => 'provider_quota_unavailable',
'status_reason_params' => [
'label' => $label,
'reason' => $reason,
'detected_keys' => implode(', ', array_slice($detectedKeys, 0, 12)),
],
'checked_at' => $httpResponse['checked_at'] ?? date('c'),
'latency_ms' => $httpResponse['latency_ms'] ?? null,
'http_status' => $httpResponse['http_status'] ?? null,
'usage' => $usagePayload,
];
}
protected function findFirstNumericPayloadValue(array $payload, array $keys): ?float
{
foreach ($payload as $key => $value) {
$normalizedKey = strtolower((string)$key);
if (in_array($normalizedKey, $keys, true) && is_numeric($value)) {
return (float)$value;
}
if (is_array($value)) {
$nested = $this->findFirstNumericPayloadValue($value, $keys);
if ($nested !== null) {
return $nested;
}
}
}
return null;
}
protected function payloadKeyPaths(array $payload, string $prefix = ''): array
{
$paths = [];
foreach ($payload as $key => $value) {
$path = $prefix === '' ? (string)$key : $prefix . '.' . (string)$key;
$paths[] = $path;
if (is_array($value)) {
array_push($paths, ...$this->payloadKeyPaths($value, $path));
}
}
return array_values(array_unique($paths));
}
protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array protected function evaluateRecaptchaProbeResponse(array $httpResponse, string $label): array
{ {
$classified = $this->classifyHttpProbeResult($httpResponse, $label); $classified = $this->classifyHttpProbeResult($httpResponse, $label);
+14
View File
@@ -72,6 +72,8 @@ class virkdata implements virkdata_i
self::requireModuleEnabled(); self::requireModuleEnabled();
// Validate the secret key // Validate the secret key
self::requireValidSecretKey(); self::requireValidSecretKey();
// VirkData has a monthly limit config, but defaults to observe mode until explicitly switched to block.
$this->reserveCompanySearchQuota($search, $endpoint, $method);
// Send the request // Send the request
$response = match ($method) { $response = match ($method) {
'GET' => self::sendGetRequest($search, $endpoint, $data), 'GET' => self::sendGetRequest($search, $endpoint, $data),
@@ -128,6 +130,18 @@ class virkdata implements virkdata_i
} }
} }
/**
* @throws Exception
*/
private function reserveCompanySearchQuota(string $search, string $endpoint, string $method): void
{
(new module_usage_service())->reserveOrFail('virkdata', 'company_search_calls', 1, [
'search' => $search,
'endpoint' => $endpoint,
'method' => strtoupper($method),
]);
}
/** /**
* @throws Exception * @throws Exception
*/ */
@@ -9,21 +9,49 @@ class economic_invoices_pdf_endpoint
use economic_endpoint_t; use economic_endpoint_t;
/** /**
* Get a PDF of a booked invoice * Get a PDF of a booked invoice.
*
* Kept as the legacy alias used by existing invoice download flows.
* @param int $id * @param int $id
* @return string * @return string
*/ */
public function get(int $id): string public function get(int $id): string
{
return $this->getBooked($id);
}
/**
* Get a PDF of a booked invoice.
* @param int $id
* @return string
*/
public function getBooked(int $id): string
{
return $this->downloadPdf('/invoices/booked/' . $id . '/pdf', 'booked_invoice_');
}
/**
* Get a PDF of a draft invoice.
* @param int $id
* @return string
*/
public function getDraft(int $id): string
{
return $this->downloadPdf('/invoices/drafts/' . $id . '/pdf', 'draft_invoice_');
}
private function downloadPdf(string $path, string $prefix): string
{ {
$unique_id = uniqid(); $unique_id = uniqid();
$file_path = '/tmp/' . $prefix . $unique_id . '.pdf';
$this->send_file_download_request( $this->send_file_download_request(
'/invoices/booked/' . $id . '/pdf', $path,
'GET', 'GET',
'', '',
false, false,
'/tmp/invoice_' . $unique_id . '.pdf' $file_path
); );
return '/tmp/invoice_' . $unique_id . '.pdf'; return $file_path;
} }
} }
@@ -3,6 +3,7 @@
namespace objects; namespace objects;
use classes\db; use classes\db;
use classes\module_usage_schema_bootstrap;
use classes\object_property; use classes\object_property;
use Exception; use Exception;
use traits\db_object_t; use traits\db_object_t;
@@ -40,6 +41,7 @@ class module_action_logs_o extends db
public function structure(): void public function structure(): void
{ {
module_usage_schema_bootstrap::ensureTables();
$this->setTable('module_usage_logs'); $this->setTable('module_usage_logs');
} }
+398 -10
View File
@@ -7624,6 +7624,45 @@ paths:
'500': '500':
$ref: '#/components/responses/InternalServerError' $ref: '#/components/responses/InternalServerError'
/collected-invoices/economic/pdf:
get:
tags:
- Invoices
summary: Download a collected invoice e-conomic PDF
description: |
Resolves the requested collected invoice context and returns a presigned URL for the
draft or booked e-conomic invoice PDF.
operationId: downloadCollectedInvoiceEconomicPdf
parameters:
- name: collected_invoice_id
in: query
required: true
description: The internal collected invoice ID
schema:
type: integer
minimum: 1
- name: type
in: query
required: true
description: Which e-conomic invoice PDF to download
schema:
type: string
enum:
- draft
- booked
responses:
'200':
description: PDF URL resolved successfully
content:
application/json:
schema:
$ref: '#/components/schemas/CollectedInvoiceEconomicPdfResponse'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'500': { $ref: '#/components/responses/InternalServerError' }
/collected-invoices/economic/v2/details: /collected-invoices/economic/v2/details:
get: get:
tags: tags:
@@ -10044,6 +10083,118 @@ paths:
'403': '403':
$ref: '#/components/responses/Forbidden' $ref: '#/components/responses/Forbidden'
/modules/usage/summary:
get:
tags:
- Modules
summary: Module usage and quota summary
description: Returns registered module usage metrics, quota status, provider snapshots, and derived operational statistics.
operationId: getModuleUsageSummary
parameters:
- in: query
name: module
required: false
schema:
type: string
- in: query
name: period
required: false
schema:
type: string
enum: [all, day, month, provider, all_time]
- in: query
name: status
required: false
schema:
type: string
enum: [ok, near_limit, exhausted, unlimited, unknown, disabled]
- in: query
name: date
required: false
schema:
type: string
format: date
responses:
'200':
description: Module usage summary returned successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ModuleUsageSummary'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/usage/{moduleKey}:
get:
tags:
- Modules
summary: Module usage details
operationId: getModuleUsageDetail
parameters:
- in: path
name: moduleKey
required: true
schema:
type: string
- in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 200
- in: query
name: date
required: false
schema:
type: string
format: date
responses:
'200':
description: Module usage detail returned successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ModuleUsageDetail'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/quotas/{moduleKey}/{metricKey}:
patch:
tags:
- Modules
summary: Update module quota setting
description: Updates quota enforcement settings and writable module-config hard limits for a registered metric.
operationId: updateModuleQuotaSetting
parameters:
- in: path
name: moduleKey
required: true
schema:
type: string
- in: path
name: metricKey
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ModuleQuotaUpdateRequest'
responses:
'200':
description: Module quota setting updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/ModuleUsageMetric'
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'409': { $ref: '#/components/responses/Conflict' }
'422': { $ref: '#/components/responses/BadRequest' }
# Module - Self-Serve Endpoints # Module - Self-Serve Endpoints
/modules/self-serve/lane/status: /modules/self-serve/lane/status:
get: get:
@@ -16507,34 +16658,242 @@ components:
type: string type: string
enum: [disabled, not_configured, configured, ok, degraded, down] enum: [disabled, not_configured, configured, ok, degraded, down]
ModuleUsageStatusEnum:
type: string
enum: [ok, near_limit, exhausted, unlimited, unknown, disabled]
ModuleUsageMetric:
type: object
properties:
module_key:
type: string
module_label:
type: string
metric_key:
type: string
metric_label:
type: string
unit:
type: string
period:
type: string
enum: [day, month, provider, all_time]
scope_type:
type: string
scope_id:
type: string
source:
type: string
enum: [internal_counter, provider_snapshot, derived]
primary:
type: boolean
writable_limit:
type: boolean
limit_source:
type: string
nullable: true
enum: [module_config, provider]
config_module:
type: string
nullable: true
config_variable:
type: string
nullable: true
used:
type: number
format: float
nullable: true
limit:
type: number
format: float
nullable: true
remaining:
type: number
format: float
nullable: true
usage_percent:
type: number
format: float
nullable: true
status:
$ref: '#/components/schemas/ModuleUsageStatusEnum'
enabled:
type: boolean
enforce_mode:
type: string
enum: [observe, block]
soft_limit_percent:
type: number
format: float
window:
type: object
properties:
start:
type: string
format: date-time
nullable: true
end:
type: string
format: date-time
nullable: true
timezone:
type: string
updated_at:
type: string
format: date-time
nullable: true
history_available:
type: boolean
version:
type: string
nullable: true
usage_available:
type: boolean
description: False when a provider responded but quota fields could not be read.
unavailable_reason:
type: string
nullable: true
enum: [unreadable_payload, missing_limit, missing_usage, invalid_limit]
detected_keys:
type: array
items:
type: string
required:
- module_key
- metric_key
- unit
- period
- source
- status
- enforce_mode
ModuleUsageSummary:
type: object
properties:
generated_at:
type: string
format: date-time
filters:
type: object
additionalProperties: true
modules:
type: array
items:
type: object
properties:
key:
type: string
label:
type: string
status:
$ref: '#/components/schemas/ModuleUsageStatusEnum'
metrics:
type: array
items:
$ref: '#/components/schemas/ModuleUsageMetric'
metrics:
type: array
items:
$ref: '#/components/schemas/ModuleUsageMetric'
required:
- generated_at
- modules
- metrics
ModuleUsageDetail:
type: object
properties:
generated_at:
type: string
format: date-time
module_key:
type: string
metrics:
type: array
items:
$ref: '#/components/schemas/ModuleUsageMetric'
history:
type: array
items:
type: object
additionalProperties: true
ModuleQuotaUpdateRequest:
type: object
properties:
enabled:
type: boolean
enforce_mode:
type: string
enum: [observe, block]
soft_limit_percent:
type: number
format: float
minimum: 1
maximum: 100
limit:
type: integer
minimum: 0
SuperuserModuleUsage: SuperuserModuleUsage:
type: object type: object
properties: properties:
provider: provider:
type: string type: string
enum: [licenseplaterecognizer] metric_key:
type: string
nullable: true
unit:
type: string
nullable: true
period:
type: string
nullable: true
calls_used: calls_used:
type: integer type: number
format: float
minimum: 0 minimum: 0
nullable: true
quota_calls: quota_calls:
type: integer type: number
minimum: 1 format: float
calls_remaining:
type: integer
minimum: 0 minimum: 0
nullable: true
calls_remaining:
type: number
format: float
minimum: 0
nullable: true
usage_percent: usage_percent:
type: number type: number
format: float format: float
minimum: 0 minimum: 0
nullable: true
status:
type: string
nullable: true
source:
type: string
nullable: true
enforce_mode:
type: string
nullable: true
version: version:
type: string type: string
nullable: true nullable: true
usage_available:
type: boolean
nullable: true
unavailable_reason:
type: string
nullable: true
enum: [unreadable_payload, missing_limit, missing_usage, invalid_limit]
detected_keys:
type: array
items:
type: string
required: required:
- provider - provider
- calls_used
- quota_calls
- calls_remaining
- usage_percent
SuperuserRuntimeMetric: SuperuserRuntimeMetric:
type: object type: object
@@ -16653,6 +17012,10 @@ components:
additionalProperties: true additionalProperties: true
usage: usage:
$ref: '#/components/schemas/SuperuserModuleUsage' $ref: '#/components/schemas/SuperuserModuleUsage'
usage_metrics:
type: array
items:
$ref: '#/components/schemas/ModuleUsageMetric'
checked_at: checked_at:
type: string type: string
format: date-time format: date-time
@@ -19324,6 +19687,31 @@ components:
- collected_invoice_id - collected_invoice_id
- internal_total - internal_total
CollectedInvoiceEconomicPdfResponse:
type: object
description: Presigned PDF URL for a draft or booked e-conomic invoice attached to a collected invoice
properties:
collected_invoice_id:
type: integer
example: 123
type:
type: string
enum:
- draft
- booked
example: booked
economic_invoice_id:
type: integer
example: 28368
url:
type: string
format: uri
required:
- collected_invoice_id
- type
- economic_invoice_id
- url
CollectedInvoiceEconomicV2DetailsResponse: CollectedInvoiceEconomicV2DetailsResponse:
type: object type: object
properties: properties:
+10 -4
View File
@@ -43,16 +43,22 @@ class cronRoute
$this->requireClassicSuperuserPermission('superuser_cron_view'); $this->requireClassicSuperuserPermission('superuser_cron_view');
$parameters = $this->getParametersAsArray(); $parameters = $this->getParametersAsArray();
$workers = (new cron_worker())->listWorkers();
try { try {
$workers['deployment'] = (new release_manager())->cronWorkerStatus($parameters); $response->success((new release_manager())->cronWorkerStatus($parameters));
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$workers = (new cron_worker())->listWorkers();
$workers['deployment'] = [ $workers['deployment'] = [
'ok' => false, 'ok' => false,
'error' => $throwable->getMessage(), 'error' => $throwable->getMessage(),
]; ];
} $workers['state'] = 'failed';
$workers['issues'] = [[
'code' => 'status_load_failed',
'severity' => 'danger',
'message' => $throwable->getMessage(),
]];
$response->success($workers); $response->success($workers);
}
}, [ }, [
'superuser_cron_view' => 'View cron worker deployment and heartbeat state', 'superuser_cron_view' => 'View cron worker deployment and heartbeat state',
]); ]);
@@ -68,7 +74,7 @@ class cronRoute
$this->actorUserId() $this->actorUserId()
); );
(new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_WORKER_DEPLOY', 'Deployed cron worker'); (new logs_o())->add('cron', 'global', 1, $this->actorUserId() ?? 0, 'CRON_WORKER_DEPLOY', 'Deployed cron worker');
$response->success($result); $response->success($result, 202);
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
$response->error(['message' => $throwable->getMessage()], 409); $response->error(['message' => $throwable->getMessage()], 409);
} }
@@ -0,0 +1,70 @@
<?php
namespace routes;
use classes\module_usage_service;
use Exception;
use traits\route_t;
class moduleUsageRoute
{
use route_t;
public function run(): void
{
$this->get('/modules/usage/summary', function () {
global $response;
$this->requirePermission('modules_usage_view');
$response->success((new module_usage_service())->summary([
'module' => $this->getParameter('module'),
'period' => $this->getParameter('period'),
'status' => $this->getParameter('status'),
'date' => $this->getParameter('date'),
]));
}, [
'modules_usage_view' => 'View module usage, quota, and statistics summaries',
]);
$this->get('/modules/usage/{moduleKey}', function () {
global $response;
$this->requirePermission('modules_usage_view');
$moduleKey = (string)($this->fromRoute('moduleKey') ?? '');
$response->success((new module_usage_service())->moduleDetail($moduleKey, [
'period' => $this->getParameter('period'),
'date' => $this->getParameter('date'),
'limit' => $this->getParameter('limit'),
]));
}, [
'modules_usage_view' => 'View detailed module usage, quota, and statistics history',
]);
$this->patch('/modules/quotas/{moduleKey}/{metricKey}', function () {
global $response;
$this->requirePermission('modules_quotas_manage');
$moduleKey = (string)($this->fromRoute('moduleKey') ?? '');
$metricKey = (string)($this->fromRoute('metricKey') ?? '');
try {
$response->success((new module_usage_service())->updateQuotaSetting(
$moduleKey,
$metricKey,
$response->getAllRequestParameters()
));
} catch (Exception $exception) {
if ($exception->getMessage() === 'quota_not_writable') {
$response->error([
'message' => 'Quota limit is not writable for this provider or derived metric.',
'code' => 'quota_not_writable',
], 409);
}
$response->error($exception->getMessage(), 422);
}
}, [
'modules_quotas_manage' => 'Manage module quota enforcement and writable hard limits',
]);
}
}
@@ -9,6 +9,7 @@ use classes\economic_transfer_queue_details_summary;
use classes\economic_v2_compare_engine; use classes\economic_v2_compare_engine;
use classes\economic_v2_line_normalizer; use classes\economic_v2_line_normalizer;
use classes\economic_v2_revenue_statistics_service; use classes\economic_v2_revenue_statistics_service;
use classes\invoice_store;
use classes\invoice_collection_bulk_action_service; use classes\invoice_collection_bulk_action_service;
use classes\invoicing_period_utils; use classes\invoicing_period_utils;
use classes\response; use classes\response;
@@ -249,6 +250,26 @@ class orderInvoicesRoute
] ]
); );
/** Collected order invoices > E-conomic PDF > GET */
$this->get('/collected-invoices/economic/pdf', function () {
global $response;
self::requirePermission('download_collected_invoice_economic_pdf');
$collected_invoice_id = $this->requireCollectedInvoiceId();
self::requireParameters(['type']);
$type = strtolower((string)self::getParameter('type'));
if (!in_array($type, ['draft', 'booked'], true)) {
$response->error('type must be either draft or booked', 400);
}
$payload = $this->downloadCollectedEconomicInvoicePdf($collected_invoice_id, $type);
$response->success($payload);
},
[
'download_collected_invoice_economic_pdf' => 'Download draft/booked e-conomic PDF for a collected order invoice.',
]
);
/** Collected order invoices > E-conomic V2 compare > GET */ /** Collected order invoices > E-conomic V2 compare > GET */
$this->get('/collected-invoices/economic/v2/compare', function () { $this->get('/collected-invoices/economic/v2/compare', function () {
global $response; global $response;
@@ -2105,6 +2126,61 @@ class orderInvoicesRoute
]; ];
} }
private function downloadCollectedEconomicInvoicePdf(int $collected_invoice_id, string $type): array
{
global $response;
$invoice = (new collected_order_invoices_o())->select($collected_invoice_id);
$invoice->requireSelected();
$this->requireCollectedInvoiceContextAccess($invoice);
$economic_invoice_id = null;
try {
$economic_invoice_id = $type === 'draft'
? $invoice->getInvoiceDraftId()
: $invoice->getInvoiceBookedId();
} catch (Exception $e) {
$response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
}
$economic_invoice_id = (int)$economic_invoice_id;
if ($economic_invoice_id <= 0) {
$response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404);
}
$economic = new economic();
$invoice_path_file = $type === 'draft'
? $economic->invoices->pdf->getDraft($economic_invoice_id)
: $economic->invoices->pdf->getBooked($economic_invoice_id);
$store_key_id = 'economic_' . $type . '_' . $economic_invoice_id;
$invoice_store = new invoice_store();
try {
$invoice_store->uploadFile('invoice_' . $store_key_id . '.pdf', $invoice_path_file);
} finally {
if (is_string($invoice_path_file) && file_exists($invoice_path_file)) {
unlink($invoice_path_file);
}
}
$user = (new authentication())->get_user();
(new logs_o())->add(
'orderInvoices',
'global',
1,
$user ? (int)$user->id : 0,
'DOWNLOAD_COLLECTED_ECONOMIC_INVOICE_PDF',
'Downloaded ' . $type . ' e-conomic invoice PDF for collected invoice ' . $collected_invoice_id
);
return [
'collected_invoice_id' => $collected_invoice_id,
'type' => $type,
'economic_invoice_id' => $economic_invoice_id,
'url' => $invoice_store->getInvoiceDownloadUrl($store_key_id),
];
}
private function requireCollectedInvoiceContextAccess(collected_order_invoices_o $invoice): void private function requireCollectedInvoiceContextAccess(collected_order_invoices_o $invoice): void
{ {
global $response; global $response;
@@ -33,6 +33,7 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu
expect($route)->toContain('/superuser/cron/workers'); expect($route)->toContain('/superuser/cron/workers');
expect($route)->toContain('/superuser/cron/workers/deploy'); expect($route)->toContain('/superuser/cron/workers/deploy');
expect($route)->toContain('$response->success($result, 202)');
expect($route)->toContain('queueTaskRun('); expect($route)->toContain('queueTaskRun(');
expect($route)->toContain('$response->success($run, 202)'); expect($route)->toContain('$response->success($run, 202)');
expect($route)->toContain('superuser_cron_view'); expect($route)->toContain('superuser_cron_view');
@@ -42,6 +43,9 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu
expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'"); expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'");
expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'"); expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
expect($manager)->toContain('deployCronWorkerAfterApiDeployment'); expect($manager)->toContain('deployCronWorkerAfterApiDeployment');
expect($manager)->toContain('deployment_kind = \'cron_worker\'');
expect($manager)->toContain('createCronWorkerDeploymentRecord');
expect($manager)->toContain('waiting_for_heartbeat');
expect($manager)->toContain('cronWorkerAutoprovisionRequired'); expect($manager)->toContain('cronWorkerAutoprovisionRequired');
expect($manager)->toContain('cron_worker_autoprovision_disabled'); expect($manager)->toContain('cron_worker_autoprovision_disabled');
expect($manager)->toContain('cron_worker_deploy_failed'); expect($manager)->toContain('cron_worker_deploy_failed');
@@ -0,0 +1,25 @@
<?php
it('registers collected-invoice economic pdf download with draft and booked support', function (): void {
$routeFile = app_path('routes/orderInvoicesRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/collected-invoices/economic/pdf');
expect($content)->toContain("requirePermission('download_collected_invoice_economic_pdf')");
expect($content)->toContain("self::requireParameters(['type'])");
expect($content)->toContain('downloadCollectedEconomicInvoicePdf');
expect($content)->toContain('getInvoiceDraftId()');
expect($content)->toContain('getInvoiceBookedId()');
});
it('supports draft and booked pdf downloads in the e-conomic pdf endpoint wrapper', function (): void {
$endpointFile = app_path('modules/economic/endpoints/invoices/economic_invoices_pdf_endpoint.php');
$content = file_get_contents($endpointFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('function getBooked(int $id): string');
expect($content)->toContain('function getDraft(int $id): string');
expect($content)->toContain("'/invoices/booked/' . \$id . '/pdf'");
expect($content)->toContain("'/invoices/drafts/' . \$id . '/pdf'");
});
@@ -31,6 +31,7 @@ it('documents economic v2 invoice paths in openapi', function (): void {
$content = economic_v2_openapi_content_or_skip(); $content = economic_v2_openapi_content_or_skip();
expect($content)->not->toBeFalse(); expect($content)->not->toBeFalse();
expect($content)->toContain('/collected-invoices/economic/pdf:');
expect($content)->toContain('/collected-invoices/economic/v2/details:'); expect($content)->toContain('/collected-invoices/economic/v2/details:');
expect($content)->toContain('/collected-invoices/economic/v2/compare:'); expect($content)->toContain('/collected-invoices/economic/v2/compare:');
expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk:'); expect($content)->toContain('/collected-invoices/economic/v2/compare/bulk:');
@@ -69,6 +70,7 @@ it('defines new reusable v2 schemas for normalization comparison versioning and
expect($content)->not->toBeFalse(); expect($content)->not->toBeFalse();
expect($content)->toContain('EconomicV2NormalizedLineItem:'); expect($content)->toContain('EconomicV2NormalizedLineItem:');
expect($content)->toContain('CollectedInvoiceEconomicPdfResponse:');
expect($content)->toContain('EconomicV2Comparison:'); expect($content)->toContain('EconomicV2Comparison:');
expect($content)->toContain('CollectedInvoiceEconomicV2CustomerSummary:'); expect($content)->toContain('CollectedInvoiceEconomicV2CustomerSummary:');
expect($content)->toContain('CollectedInvoiceEconomicV2RevenueStatisticsResponse:'); expect($content)->toContain('CollectedInvoiceEconomicV2RevenueStatisticsResponse:');
@@ -0,0 +1,25 @@
<?php
it('documents module usage summary, detail, and quota update endpoints in openapi', function (): void {
$content = file_get_contents(app_path('openapi.yaml'));
expect($content)->toContain('/modules/usage/summary:')
->and($content)->toContain('operationId: getModuleUsageSummary')
->and($content)->toContain('/modules/usage/{moduleKey}:')
->and($content)->toContain('operationId: getModuleUsageDetail')
->and($content)->toContain('/modules/quotas/{moduleKey}/{metricKey}:')
->and($content)->toContain('operationId: updateModuleQuotaSetting')
->and($content)->toContain('ModuleUsageSummary:')
->and($content)->toContain('ModuleUsageDetail:')
->and($content)->toContain('ModuleUsageMetric:')
->and($content)->toContain('ModuleQuotaUpdateRequest:');
});
it('documents unavailable provider quota fields in openapi', function (): void {
$content = file_get_contents(app_path('openapi.yaml'));
expect($content)->toContain('usage_available:')
->and($content)->toContain('unavailable_reason:')
->and($content)->toContain('enum: [unreadable_payload, missing_limit, missing_usage, invalid_limit]')
->and($content)->toContain('detected_keys:');
});
@@ -0,0 +1,89 @@
<?php
app_require('classes/module_usage_registry.php');
app_require('classes/module_usage_service.php');
use classes\module_usage_registry;
use classes\module_usage_service;
it('registers existing writable quota metrics with their current enforcement defaults', function (): void {
$registry = new module_usage_registry();
$motorApi = $registry->find('motorapi', 'lookup_calls');
$fxRates = $registry->find('fxratesapi', 'rate_fetch_calls');
$virkData = $registry->find('virkdata', 'company_search_calls');
expect($motorApi)->not->toBeNull()
->and($motorApi['config_module'])->toBe('motorapi')
->and($motorApi['config_variable'])->toBe('daily_limit')
->and($motorApi['default_enforce_mode'])->toBe('block')
->and($motorApi['writable_limit'])->toBeTrue()
->and($fxRates)->not->toBeNull()
->and($fxRates['config_variable'])->toBe('daily_limit')
->and($fxRates['default_enforce_mode'])->toBe('block')
->and($virkData)->not->toBeNull()
->and($virkData['config_variable'])->toBe('monthly_limit')
->and($virkData['default_enforce_mode'])->toBe('observe');
});
it('normalizes provider usage payloads into module usage metrics without requiring a database', function (): void {
$previousDb = $GLOBALS['db'] ?? null;
unset($GLOBALS['db']);
try {
$metric = (new module_usage_service())->recordProviderSnapshotFromLegacyUsage('licenseplaterecognizer', [
'calls_used' => 2250,
'quota_calls' => 2500,
'calls_remaining' => 250,
'usage_percent' => 90.0,
'version' => '1.54.0',
]);
} finally {
if ($previousDb !== null) {
$GLOBALS['db'] = $previousDb;
}
}
expect($metric)->not->toBeNull()
->and($metric['module_key'])->toBe('licenseplaterecognizer')
->and($metric['metric_key'])->toBe('plate_recognition_calls')
->and($metric['used'])->toBe(2250.0)
->and($metric['limit'])->toBe(2500.0)
->and($metric['remaining'])->toBe(250.0)
->and($metric['usage_percent'])->toBe(90.0)
->and($metric['status'])->toBe('near_limit')
->and($metric['version'])->toBe('1.54.0');
});
it('normalizes unavailable provider quota payloads into placeholder metrics without requiring a database', function (): void {
$previousDb = $GLOBALS['db'] ?? null;
unset($GLOBALS['db']);
try {
$metric = (new module_usage_service())->recordProviderSnapshotFromLegacyUsage('email', [
'usage_available' => false,
'status' => 'unknown',
'calls_used' => null,
'quota_calls' => null,
'calls_remaining' => null,
'usage_percent' => null,
'unavailable_reason' => 'missing_limit',
'detected_keys' => ['plan', 'usage.calls'],
]);
} finally {
if ($previousDb !== null) {
$GLOBALS['db'] = $previousDb;
}
}
expect($metric)->not->toBeNull()
->and($metric['module_key'])->toBe('email')
->and($metric['metric_key'])->toBe('mailersend_messages')
->and($metric['used'])->toBeNull()
->and($metric['limit'])->toBeNull()
->and($metric['usage_percent'])->toBeNull()
->and($metric['status'])->toBe('unknown')
->and($metric['usage_available'])->toBeFalse()
->and($metric['unavailable_reason'])->toBe('missing_limit')
->and($metric['detected_keys'])->toBe(['plan', 'usage.calls']);
});
@@ -470,6 +470,127 @@ it('requires Coolify cron worker autoprovisioning for API deployments by default
$managerSource = file_get_contents(app_path('classes/release_manager.php')); $managerSource = file_get_contents(app_path('classes/release_manager.php'));
expect($managerSource)->toContain('Cron worker deployment is required for API deployments'); expect($managerSource)->toContain('Cron worker deployment is required for API deployments');
});
it('classifies cron worker deployment and heartbeat lifecycle states', function (): void {
$manager = new release_manager();
$healthMethod = new ReflectionMethod(release_manager::class, 'cronWorkerHealth');
$healthMethod->setAccessible(true);
expect($healthMethod->invoke($manager, null, null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
->toBe('needs_deploy');
expect($healthMethod->invoke($manager, ['id' => 4], null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
->toBe('needs_deploy');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
'status' => 'deploying',
'created_at' => date('Y-m-d H:i:s'),
])['state'])->toBe('deploying');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
'status' => 'deployed',
'completed_at' => date('Y-m-d H:i:s'),
])['state'])->toBe('waiting_for_heartbeat');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [
['status' => 'running', 'stale' => false],
], ['running' => 1, 'stale' => 0, 'failed' => 0], [
'status' => 'deployed',
])['state'])->toBe('healthy');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
'status' => 'deployed',
'completed_at' => '2020-01-01 00:00:00',
])['state'])->toBe('failed');
});
it('extracts Coolify cron deployment operation identifiers from provider payloads', function (): void {
$manager = new release_manager();
$operationMethod = new ReflectionMethod(release_manager::class, 'coolifyDeploymentOperationId');
$operationMethod->setAccessible(true);
expect($operationMethod->invoke($manager, ['deployment' => ['deployment_uuid' => 'deployment-123']]))
->toBe('deployment-123');
expect($operationMethod->invoke($manager, ['data' => ['uuid' => 'operation-456']]))
->toBe('operation-456');
expect($operationMethod->invoke($manager, ['message' => 'queued']))
->toBeNull();
});
it('detects missing Coolify cron worker resources from provider errors', function (): void {
$method = new ReflectionMethod(release_manager::class, 'coolifyResourceMissing');
$method->setAccessible(true);
expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 404')))->toBeTrue();
expect($method->invoke(null, new RuntimeException('Application not found')))->toBeTrue();
expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 401')))->toBeFalse();
});
it('classifies missing Coolify cron worker resources as repairable', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$readiness->setAccessible(true);
$result = $readiness->invoke($manager, [
'id' => 17,
'app' => 'api',
'coolify_instance_id' => 3,
'repository' => 'copenhagentruckwash/api',
], [
'id' => 71,
'app' => 'cron',
'coolify_instance_id' => 3,
'coolify_service_uuid' => 'missing-cron-worker',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
], [
'configured' => true,
'missing' => true,
]);
expect($result['action'])->toBe('repair');
expect($result['can_deploy'])->toBeTrue();
expect(array_column($result['issues'], 'code'))->toContain('missing_coolify_worker_resource');
});
it('repairs from an existing cron target when the API target is absent', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$readiness->setAccessible(true);
$result = $readiness->invoke($manager, null, [
'id' => 71,
'app' => 'cron',
'coolify_instance_id' => 3,
'coolify_service_uuid' => 'missing-cron-worker',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
], [
'configured' => true,
'missing' => true,
]);
expect($result['action'])->toBe('repair');
expect($result['can_deploy'])->toBeTrue();
expect(array_column($result['issues'], 'code'))->toContain('repairable_cron_target');
});
it('blocks cron worker deployment without an API target or deployable cron context', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$readiness->setAccessible(true);
$result = $readiness->invoke($manager, null, [
'id' => 71,
'app' => 'cron',
'coolify_instance_id' => null,
'coolify_service_uuid' => 'missing-cron-worker',
'repository' => '',
'branch' => 'master',
], [
'configured' => true,
'missing' => true,
]);
expect($result['action'])->toBe('blocked');
expect($result['can_deploy'])->toBeFalse();
expect(array_column($result['issues'], 'code'))->toContain('missing_api_target');
}); });
it('builds explicit Coolify application route labels for release API targets', function (): void { it('builds explicit Coolify application route labels for release API targets', function (): void {
@@ -778,6 +899,7 @@ it('builds cron worker runtime environment from API runtime keys and cron contex
$env = $runtimeEnv->invoke($manager, [ $env = $runtimeEnv->invoke($manager, [
'app' => 'cron', 'app' => 'cron',
'commit_sha' => $selectedCommit, 'commit_sha' => $selectedCommit,
'coolify_service_uuid' => 'cron-worker-resource-uuid',
], [ ], [
'coolify_env' => [ 'coolify_env' => [
'CRON_WORKER_NAME' => 'release-internal-cron-worker', 'CRON_WORKER_NAME' => 'release-internal-cron-worker',
@@ -789,6 +911,7 @@ it('builds cron worker runtime environment from API runtime keys and cron contex
expect($env['CONFIG_DB_HOST'])->toBe('db.example.test'); expect($env['CONFIG_DB_HOST'])->toBe('db.example.test');
expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker'); expect($env['CRON_WORKER_NAME'])->toBe('release-internal-cron-worker');
expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker'); expect($env['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
expect($env['CRON_WORKER_COOLIFY_RESOURCE_UUID'])->toBe('cron-worker-resource-uuid');
expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit); expect($env['CRON_WORKER_COMMIT_SHA'])->toBe($selectedCommit);
expect($env['API_COMMIT_SHA'])->toBe($selectedCommit); expect($env['API_COMMIT_SHA'])->toBe($selectedCommit);
expect($env['COMMIT_SHA'])->toBe($selectedCommit); expect($env['COMMIT_SHA'])->toBe($selectedCommit);
@@ -62,6 +62,11 @@ class SuperuserSystemStatusServiceProbeDouble extends superuser_system_status_se
return $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label); return $this->evaluateLicensePlateRecognizerProbeResponse($httpResponse, $label);
} }
public function evaluateProviderQuotaProbeResponsePublic(array $httpResponse, string $label, string $moduleKey): array
{
return $this->evaluateProviderQuotaProbeResponse($httpResponse, $label, $moduleKey);
}
public function normalizeWarningEntriesPublic(array $warnings): array public function normalizeWarningEntriesPublic(array $warnings): array
{ {
return $this->normalizeWarningEntries($warnings); return $this->normalizeWarningEntries($warnings);
@@ -393,7 +398,7 @@ it('builds the expected http probe requests for newly supported modules', functi
null, null,
'GET', 'GET',
null, null,
false, true,
], ],
'motorapi' => [ 'motorapi' => [
'probeMotorApiModulePublic', 'probeMotorApiModulePublic',
@@ -403,7 +408,7 @@ it('builds the expected http probe requests for newly supported modules', functi
null, null,
'GET', 'GET',
null, null,
false, true,
], ],
'fxratesapi' => [ 'fxratesapi' => [
'probeFxRatesApiModulePublic', 'probeFxRatesApiModulePublic',
@@ -581,8 +586,17 @@ it('evaluates license plate recognizer usage and quota health from the info payl
'unreadable payload' => [ 'unreadable payload' => [
'not-json', 'not-json',
'degraded', 'degraded',
'licenseplaterecognizer_usage_unreadable', 'provider_quota_unavailable',
null, [
'provider' => 'licenseplaterecognizer',
'usage_available' => false,
'status' => 'unknown',
'calls_used' => null,
'quota_calls' => null,
'calls_remaining' => null,
'usage_percent' => null,
'unavailable_reason' => 'unreadable_payload',
],
], ],
'missing quota' => [ 'missing quota' => [
json_encode([ json_encode([
@@ -590,9 +604,51 @@ it('evaluates license plate recognizer usage and quota health from the info payl
'usage' => ['calls' => 10], 'usage' => ['calls' => 10],
]), ]),
'degraded', 'degraded',
'licenseplaterecognizer_usage_missing', 'provider_quota_unavailable',
null, [
'provider' => 'licenseplaterecognizer',
'usage_available' => false,
'status' => 'unknown',
'calls_used' => null,
'quota_calls' => null,
'calls_remaining' => null,
'usage_percent' => null,
'unavailable_reason' => 'missing_limit',
], ],
],
]);
it('degrades provider quota probes when the provider response does not expose readable quota fields', function (
string $body,
string $expectedReason
): void {
$service = new SuperuserSystemStatusServiceProbeDouble();
$result = $service->evaluateProviderQuotaProbeResponsePublic([
'checked_at' => '2026-04-08T17:00:00+00:00',
'latency_ms' => 7.5,
'http_status' => 200,
'body' => $body,
'error' => '',
], 'MailerSend', 'email');
expect($result['status'])->toBe('degraded')
->and($result['status_reason_key'])->toBe('provider_quota_unavailable')
->and($result['usage'])->toMatchArray([
'provider' => 'email',
'usage_available' => false,
'status' => 'unknown',
'calls_used' => null,
'quota_calls' => null,
'calls_remaining' => null,
'usage_percent' => null,
'unavailable_reason' => $expectedReason,
]);
})->with([
'invalid json' => ['not-json', 'unreadable_payload'],
'missing limit' => [json_encode(['usage' => ['calls' => 12], 'plan' => 'pro']), 'missing_limit'],
'missing usage' => [json_encode(['quota' => 100, 'plan' => 'pro']), 'missing_usage'],
'invalid limit' => [json_encode(['used' => 12, 'quota' => 0]), 'invalid_limit'],
]); ]);
it('carries license plate recognizer usage from probe results into module status rows', function (): void { it('carries license plate recognizer usage from probe results into module status rows', function (): void {