Files
api/services/nginx/app/routes/economicInvoiceRoute.php
T
OpenClaw 51a87655d6 feat(auth): add scope-based access control to all existing routes (TRU-149)
Adds a scope-based access control layer to all 81 existing API routes.
Sits alongside existing session-cookie auth (does not replace it).

What this PR does:
- Audits every existing route and documents required scope per route
  (see documentation/auth/route-scope-audit.md)
- Adds classes/auth/scope.php with 10 scope constants and role→scope defaults
- Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole
- Applies require*() calls to all 81 existing routes
- Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines)

Coexistence note:
This branch's classes/auth/scope.php is a stub that will be replaced
by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that
PR merges first. The two have compatible APIs.

Refs: TRU-149
2026-08-17 11:43:13 +00:00

661 lines
28 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\economic;
use classes\economic_transfer_queue;
use classes\response;
use classes\router;
use economic_invoice_draft_mo;
use objects\departments_o;
use objects\economic_module_orders;
use objects\logs_o;
use objects\orders_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class economicInvoiceRoute
{
use route_t;
public function run(): void
{
global /** @var response $response */
/** @var router $router */
$router, $response;
$this->post('/economic/invoice/draft/export', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/export');
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('economic_invoice_draft', 'global', 1, 0, 'ECONOMIC_INVOICE_DRAFT_EXPORT', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$order_id = $response->getRequestParameter('order_id');
if (!isset($order_id)) {
$response->error('Order ID is required', 400);
}
if (!is_numeric($order_id) || (int)$order_id < 1) {
$response->error('Order ID must be a positive number', 400);
}
$order = (new orders_o())->getOrderById((int)$order_id);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
try {
$this->assertOrderCanBeExportedToEconomic((int)$order_id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
if (!$this->isEconomicTransferQueueAvailable()) {
try {
$result = $this->exportOrderDraftSynchronously((int)$order_id, (int)$user->id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add(
'economic_invoice_draft',
'global',
1,
(int)$user->id,
'ECONOMIC_INVOICE_DRAFT_EXPORT_FALLBACK',
'Processed economic invoice draft export synchronously because queue dependencies are unavailable'
);
$response->success([
'message' => 'Economic invoice draft export processed synchronously',
'mode' => 'synchronous_fallback',
'result' => $result,
]);
}
$queue = new economic_transfer_queue();
$job = $queue->enqueue(
economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT,
[
'order_id' => (int)$order_id,
'requested_by' => (int)$user->id,
],
(int)$user->id
);
$job_id = (int)($job['id'] ?? 0);
if ($job_id < 1) {
$response->error('Failed to enqueue draft export job: missing queue job id in enqueue response', 500);
}
(new logs_o())->add('economic_invoice_draft', 'global', 1, $user->id, 'ECONOMIC_INVOICE_DRAFT_EXPORT_QUEUED', 'Queued economic invoice draft export');
$response->success([
'message' => 'Economic invoice draft export queued',
'job_id' => $job_id,
'job' => $job,
], 202);
}, [
'economic_invoice_draft_export' => 'Export an economic invoice draft'
]);
$this->delete('/economic/invoice/draft/delete', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/delete');
global /** @var response $response */
$response;
$this->requirePermission('economic_invoice_draft_delete');
$user = (new authentication())->get_user();
if ($user) {
$order_id = $response->getRequestParameter('order_id');
if (!isset($order_id)) {
$response->error('Order ID is required', 400);
}
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
$response->error('No economic invoice draft found', 404);
}
$economic_invoice_draft = (new economic_invoice_draft_mo());
$invoiceDraftId = $economic_module_orders->economic_invoice_draft_id->value();
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->invoicePerOrder()) {
if ($customer->hasOpenInvoiceDraft()) {
if ((int)$customer->getOpenInvoiceDraft() === (int)$invoiceDraftId) {
$customer->deleteOpenInvoiceDraft();
}
}
}
$economic_invoice_draft->deleteInvoiceDraft($invoiceDraftId);
$economic_module_orders->economic_invoice_draft_id->set(null);
(new logs_o())->add('economic_invoice_draft', 'global', 1, $user->id, 'ECONOMIC_INVOICE_DRAFT_DELETE', 'Successfully deleted an economic invoice draft');
$response->success($economic_module_orders->getArray());
} else {
(new logs_o())->add('economic_invoice_draft', 'global', 1, 0, 'ECONOMIC_INVOICE_DRAFT_DELETE', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
}, [
'economic_invoice_draft_delete' => 'Delete an economic invoice draft'
]);
$this->post('/economic/invoice/export', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/export');
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('economic_invoice', 'global', 1, 0, 'ECONOMIC_INVOICE_EXPORT', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$order_id = $response->getRequestParameter('order_id');
if (!isset($order_id)) {
$response->error('Order ID is required', 400);
}
if (!is_numeric($order_id) || (int)$order_id < 1) {
$response->error('Order ID must be a positive number', 400);
}
$order = (new orders_o())->getOrderById((int)$order_id);
if (!$order->exists()) {
$response->error('Order not found', 404);
}
$customer = (new orders_o())->getCustomerByOrderId((int)$order_id);
if (!$customer->exists()) {
$response->error('Customer not found', 404);
}
try {
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId((int)$order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
$response->error('No economic invoice draft found', 404);
}
if ((int)$economic_module_orders->economic_invoice_id->value() > 0) {
$response->error('An invoice has already been created, invoice ID: ' . (int)$economic_module_orders->economic_invoice_id->value(), 400);
}
if (!$this->isEconomicTransferQueueAvailable()) {
try {
$result = $this->exportOrderInvoiceSynchronously((int)$order_id, (int)$user->id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
(new logs_o())->add(
'economic_invoice',
'global',
1,
(int)$user->id,
'ECONOMIC_INVOICE_EXPORT_FALLBACK',
'Processed economic invoice export synchronously because queue dependencies are unavailable'
);
$response->success([
'message' => 'Economic invoice export processed synchronously',
'mode' => 'synchronous_fallback',
'result' => $result,
]);
}
$queue = new economic_transfer_queue();
$job = $queue->enqueue(
economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT,
[
'order_id' => (int)$order_id,
'requested_by' => (int)$user->id,
],
(int)$user->id
);
$job_id = (int)($job['id'] ?? 0);
if ($job_id < 1) {
$response->error('Failed to enqueue invoice export job: missing queue job id in enqueue response', 500);
}
(new logs_o())->add('economic_invoice', 'global', 1, $user->id, 'ECONOMIC_INVOICE_EXPORT_QUEUED', 'Queued economic invoice export');
$response->success([
'message' => 'Economic invoice export queued',
'job_id' => $job_id,
'job' => $job,
], 202);
}, [
'economic_invoice_export' => 'Export an economic invoice'
]);
$this->get('/economic/invoice/draft/export/status', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/invoice/draft/export/status');
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $response->getRequestParameter('job_id');
if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) {
$response->error('job_id is required and must be a positive number', 400);
}
$queue = new economic_transfer_queue();
$job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
$response->error('Draft export queue job not found', 404);
}
$response->success($job);
}, [
'economic_invoice_draft_export' => 'Read queue status for economic draft invoice export'
]);
$this->post('/economic/invoice/draft/export/retry', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/export/retry');
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $response->getRequestParameter('job_id');
if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) {
$response->error('job_id is required and must be a positive number', 400);
}
$queue = new economic_transfer_queue();
$existing_job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
$response->error('Draft export queue job not found', 404);
}
try {
$job = $queue->retryJobForUser((int)$job_id, (int)$user->id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$response->success([
'message' => 'Draft export queue job retried',
'job' => $job,
]);
}, [
'economic_invoice_draft_export' => 'Retry failed queue job for economic draft invoice export'
]);
$this->get('/economic/invoice/export/status', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/invoice/export/status');
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $response->getRequestParameter('job_id');
if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) {
$response->error('job_id is required and must be a positive number', 400);
}
$queue = new economic_transfer_queue();
$job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
$response->error('Invoice export queue job not found', 404);
}
$response->success($job);
}, [
'economic_invoice_export' => 'Read queue status for economic invoice export'
]);
$this->post('/economic/invoice/export/retry', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/export/retry');
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $response->getRequestParameter('job_id');
if (!isset($job_id) || !is_numeric($job_id) || (int)$job_id < 1) {
$response->error('job_id is required and must be a positive number', 400);
}
$queue = new economic_transfer_queue();
$existing_job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
$response->error('Invoice export queue job not found', 404);
}
try {
$job = $queue->retryJobForUser((int)$job_id, (int)$user->id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
$response->success([
'message' => 'Invoice export queue job retried',
'job' => $job,
]);
}, [
'economic_invoice_export' => 'Retry failed queue job for economic invoice export'
]);
}
private function isEconomicTransferQueueAvailable(): bool
{
return class_exists('\\classes\\economic_transfer_executor')
&& class_exists('\\classes\\economic_transfer_queue_schema_bootstrap')
&& class_exists(economic_transfer_queue::class);
}
/**
* @throws \Exception
*/
private function exportOrderDraftSynchronously(int $order_id, int $user_id): array
{
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new \Exception('Order not found');
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
$invoice_id = (int)$economic_module_orders->economic_invoice_id->value();
if ($invoice_id > 0) {
throw new \Exception('An invoice has already been created, invoice ID: ' . $invoice_id);
}
$order_items = (new orders_o())->getOrderItems($order_id);
$order_items = (new orders_o())->applyDepartmentPrices($order_items, $order->department_id->value());
if (count($order_items) === 0) {
throw new \Exception('No order items found');
}
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->exists()) {
throw new \Exception('Customer not found');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$customer_economic = $customer->getCustomerEcocomicData()->economic_customer;
$economic_invoice_draft = new economic_invoice_draft_mo();
$economic_invoice_draft->setCustomerNumber((int)$customer_economic->customer_number);
$economic_invoice_draft->setRecipient(
$customer_economic->name ?? 'Ukendt',
$customer_economic->address ?? 'Ukendt',
$customer_economic->zip ?? 'Ukendt',
$customer_economic->city ?? 'Ukendt'
);
$department = (new departments_o())->getDepartmentById($order->department_id->value());
$this->addTheDepartmentDateReference($economic_invoice_draft, $department['name'], $order);
foreach ($order_items as $order_item) {
$this->addOrderItemToInvoice(
$customer,
$order,
$order_item,
$economic_invoice_draft,
(int)($order_item['quantity'] ?? 1)
);
}
$result = null;
if ($customer->hasOpenInvoiceDraft() && !$customer->invoicePerOrder()) {
$open_invoice_draft = (int)$customer->getOpenInvoiceDraft();
$result = $this->addOrderToInvoiceDraft($open_invoice_draft, $order, $customer, $order_items);
}
if ($result === null) {
$result = $economic_invoice_draft->createInvoiceDraftExample();
}
if (!isset($result->draftInvoiceNumber) && !isset($result->lines[0])) {
(new logs_o())->add(
'economic_invoice_draft',
'global',
3,
$user_id,
'ECONOMIC_INVOICE_DRAFT_EXPORT',
'Failed to create economic invoice draft'
);
$message = $result->message ?? 'Failed to create economic invoice draft';
throw new \Exception((string)$message);
}
if ($economic_module_orders->economic_invoice_draft_id->value() > 0) {
$economic_invoice_draft->deleteInvoiceDraft($economic_module_orders->economic_invoice_draft_id->value());
}
$new_draft_id = (int)($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft());
$economic_module_orders->economic_invoice_draft_id->set($new_draft_id);
if (!$customer->invoicePerOrder()) {
$customer->setOpenInvoiceDraft($new_draft_id);
} else {
$customer->unsetOpenInvoiceDraft();
}
(new logs_o())->add(
'economic_invoice_draft',
'global',
1,
$user_id,
'ECONOMIC_INVOICE_DRAFT_EXPORT',
'Successfully exported an economic invoice draft'
);
return $economic_module_orders->getArray();
}
/**
* @throws \Exception
*/
private function exportOrderInvoiceSynchronously(int $order_id, int $user_id): array
{
$order = (new orders_o())->getOrderById($order_id);
if (!$order->exists()) {
throw new \Exception('Order not found');
}
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->exists()) {
throw new \Exception('Customer not found');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
if ($economic_module_orders->economic_invoice_draft_id->value() === 0) {
throw new \Exception('No economic invoice draft found');
}
$invoice_id = (int)$economic_module_orders->economic_invoice_id->value();
if ($invoice_id > 0) {
throw new \Exception('An invoice has already been created, invoice ID: ' . $invoice_id);
}
$invoice_draft_id = (int)$economic_module_orders->economic_invoice_draft_id->value();
$economic_invoice_draft = new economic_invoice_draft_mo();
$result = $economic_invoice_draft->publishInvoiceDraft($invoice_draft_id);
if (!isset($result->bookedInvoiceNumber)) {
(new logs_o())->add(
'economic_invoice',
'global',
3,
$user_id,
'ECONOMIC_INVOICE_EXPORT',
'Failed to create economic invoice from draft: ' . $invoice_draft_id
);
$message = $result->message ?? 'Failed to create economic invoice';
throw new \Exception((string)$message);
}
$economic_module_orders = (new economic_module_orders())->getByOrderId($order_id);
$economic_module_orders->economic_invoice_id->set((int)$result->bookedInvoiceNumber);
$customer->unsetOpenInvoiceDraft();
(new logs_o())->add(
'economic_invoice',
'global',
1,
$user_id,
'ECONOMIC_INVOICE_EXPORT',
'Successfully exported an economic invoice'
);
return $economic_module_orders->getArray();
}
private function addTheDepartmentDateReference(economic_invoice_draft_mo $economic_invoice_draft, mixed $department_name, orders_o $order): void
{
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
$economic_invoice_draft->addLineTEXT("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
if ($order->reference->value() !== '') {
$economic_invoice_draft->addLineTEXT('Reference:');
if (str_contains($order->reference->value(), "\n")) {
foreach (explode("\n", $order->reference->value()) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order->reference->value());
}
}
$line_reg = '';
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
}
$economic_invoice_draft->addLineTEXT($line_reg);
if ($order->notes->value() !== '') {
$economic_invoice_draft->addLineTEXT('Notat:');
if (str_contains($order->notes->value(), "\n")) {
foreach (explode("\n", $order->notes->value()) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order->notes->value());
}
}
}
/**
* @throws \Exception
*/
private function addOrderItemToInvoice(users_o $customer, orders_o $order, mixed $order_item, economic_invoice_draft_mo $economic_invoice_draft, int $quantity = 1): void
{
$department = $order->getDepartmentByOrderId($order->id);
$economic_department_id = $department['economic_department_id'] ?? 0;
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
$line_price = (float)$order_item['price'];
$product_price = (float)$order_item['product']['price'];
$discount_percentage = 0.0;
if (abs($product_price) > 0.00001 && $line_price < $product_price) {
$discount_percentage = round((($product_price - $line_price) / $product_price) * 100, 10);
}
$economic_invoice_draft->addLine(
(string)$order_item['product']['economic_product_id'],
(string)$order_item['product']['name'],
(int)$quantity,
$line_price,
$discount_percentage,
(int)$economic_department_id,
(int)$economic_dimension_id
);
if ($order_item['reference'] !== '') {
$economic_invoice_draft->addLineTEXT('Reference:');
if (str_contains($order_item['reference'], "\n")) {
foreach (explode("\n", $order_item['reference']) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order_item['reference']);
}
}
if ($customer->doesUserHaveAttribute('requiresRegistrationNumbersInvoice')) {
$line_reg = '';
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
}
$economic_invoice_draft->addLineTEXT($line_reg);
}
if ($order_item['notes'] !== '') {
$economic_invoice_draft->addLineTEXT('Notat:');
if (str_contains($order_item['notes'], "\n")) {
foreach (explode("\n", $order_item['notes']) as $line) {
$economic_invoice_draft->addLineTEXT('# ' . $line);
}
} else {
$economic_invoice_draft->addLineTEXT('# ' . $order_item['notes']);
}
}
}
private function addOrderToInvoiceDraft(int $economic_invoice_draft_id, orders_o $order, users_o $customer, array $order_items): object
{
$economic_invoice_draft = new economic_invoice_draft_mo();
$economic_invoice_draft->addLineTEXT('');
$economic_invoice_draft->addLineTEXT('');
$this->addTheDepartmentDateReference($economic_invoice_draft, $order->getDepartmentByOrderId($order->id)['name'], $order);
foreach ($order_items as $order_item) {
$this->addOrderItemToInvoice(
$customer,
$order,
$order_item,
$economic_invoice_draft,
(int)($order_item['quantity'] ?? 1)
);
}
return $economic_invoice_draft->addLinesToInvoiceDraft($economic_invoice_draft_id);
}
private function ensureEconomicTransferQueueIsAvailable(): void
{
global $response;
if (!$this->isEconomicTransferQueueAvailable()) {
$response->error('Economic transfer queue is unavailable in this deployment', 503);
}
}
/**
* @throws \Exception
*/
private function assertOrderCanBeExportedToEconomic(int $order_id): void
{
$customer = (new orders_o())->getCustomerByOrderId($order_id);
if (!$customer->exists()) {
throw new \Exception('Customer not found');
}
(new economic())->assertCustomerNumberIsNotDraft((int)$customer->customer_number->value());
}
}