From ba23ad6e8f390086c3a257f9795840cd66c1d37f Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 8 Apr 2026 11:20:08 +0200 Subject: [PATCH] Add `economic_transfer_executor` and `economic_transfer_queue` classes for handling e-conomic invoice transfer logic, queue management, and processing. Include unit tests for Redis cache validation. --- openapi.yaml | 510 +- .../classes/economic_transfer_executor.php | 432 + .../app/classes/economic_transfer_queue.php | 480 + ...onomic_transfer_queue_schema_bootstrap.php | 47 + services/nginx/app/cli.php | 10 + services/nginx/app/cron/Cron.php | 27 + services/nginx/app/index.php | 24 +- .../helpers/economic_invoice_draft.php | 27 +- .../objects/collected_order_invoices_o.php | 5 + services/nginx/app/objects/orders_o.php | 41 + services/nginx/app/openapi.yaml | 16291 ++++++++++++++++ .../nginx/app/routes/economicInvoiceRoute.php | 525 +- .../nginx/app/routes/orderInvoicesRoute.php | 392 +- ...omicInvoiceDraftZeroItemSkipWiringTest.php | 10 + .../EconomicTransferOrderItemSkipTest.php | 53 + ...omicTransferQueueAvailabilityGuardTest.php | 78 + ...onomicTransferQueueCronIntegrationTest.php | 26 + .../EconomicTransferQueueHardeningTest.php | 13 + .../EconomicTransferQueueOpenApiSpecTest.php | 51 + ...omicTransferQueueRouteRegistrationTest.php | 44 + ...onomicTransferQueueSchemaBootstrapTest.php | 27 + .../OrdersRegistrationDateRangeQueryTest.php | 127 + .../AutoloadRedisCacheValidationTest.php | 11 + 23 files changed, 18863 insertions(+), 388 deletions(-) create mode 100644 services/nginx/app/classes/economic_transfer_executor.php create mode 100644 services/nginx/app/classes/economic_transfer_queue.php create mode 100644 services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php create mode 100644 services/nginx/app/openapi.yaml create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftZeroItemSkipWiringTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicTransferOrderItemSkipTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronIntegrationTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueRouteRegistrationTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php create mode 100644 services/nginx/app/tests/Unit/Orders/OrdersRegistrationDateRangeQueryTest.php create mode 100644 services/nginx/app/tests/Unit/Router/AutoloadRedisCacheValidationTest.php diff --git a/openapi.yaml b/openapi.yaml index 39894817..d66e21dc 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5992,6 +5992,139 @@ paths: application/json: schema: {} + /collected-invoices/economic: + post: + tags: + - Invoices + summary: Queue transfer of collected invoice to e-conomic + description: | + Queues collected invoice transfer to e-conomic. + Processing runs asynchronously and can be tracked through queue status endpoints. + operationId: queueCollectedInvoiceEconomicTransfer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: integer + minimum: 1 + send_as_is: + type: boolean + default: false + responses: + '202': + description: Collected invoice transfer queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '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/queue: + get: + tags: + - Invoices + summary: List collected-invoice e-conomic transfer queue jobs + operationId: listCollectedInvoiceEconomicQueueJobs + parameters: + - name: status + in: query + required: false + description: Comma-separated queue statuses to filter by. + schema: + type: string + example: "QUEUED,FAILED" + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Queue jobs retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueListResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/status: + get: + tags: + - Invoices + summary: Get collected-invoice e-conomic transfer queue job status + operationId: getCollectedInvoiceEconomicQueueJobStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '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/queue/retry: + post: + tags: + - Invoices + summary: Retry failed collected-invoice queue job + operationId: retryCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '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/compare: get: tags: @@ -7672,39 +7805,175 @@ paths: post: tags: - Modules - summary: Export draft invoice to e-conomic - description: Export a draft invoice to e-conomic - operationId: exportDraftInvoiceToEconomic + summary: Queue draft invoice export to e-conomic + description: Queue a draft invoice export job for asynchronous processing. + operationId: queueDraftInvoiceExportToEconomic requestBody: - required: false + required: true content: application/json: - schema: {} + schema: + type: object + required: [order_id] + properties: + order_id: + type: integer + minimum: 1 responses: - '200': - description: Draft invoice exported successfully + '202': + description: Draft invoice export queued content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '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' } + + /economic/invoice/draft/export/status: + get: + tags: + - Modules + summary: Get queued draft export job status + operationId: getDraftInvoiceExportQueueStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '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' } + + /economic/invoice/draft/export/retry: + post: + tags: + - Modules + summary: Retry failed draft export queue job + operationId: retryDraftInvoiceExportQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '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' } /economic/invoice/export: post: tags: - Modules - summary: Export invoice to e-conomic - description: Export a booked invoice to e-conomic - operationId: exportInvoiceToEconomic + summary: Queue invoice export to e-conomic + description: Queue booked invoice export job for asynchronous processing. + operationId: queueInvoiceExportToEconomic requestBody: - required: false + required: true content: application/json: - schema: {} + schema: + type: object + required: [order_id] + properties: + order_id: + type: integer + minimum: 1 responses: - '200': - description: Invoice exported successfully + '202': + description: Invoice export queued content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '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' } + + /economic/invoice/export/status: + get: + tags: + - Modules + summary: Get queued invoice export job status + operationId: getInvoiceExportQueueStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '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' } + + /economic/invoice/export/retry: + post: + tags: + - Modules + summary: Retry failed invoice export queue job + operationId: retryInvoiceExportQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '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' } # Module - Stripe Endpoints /modules/stripe/customers: @@ -8135,7 +8404,7 @@ paths: properties: message: type: string - example: Failed to execute command: Failed to open property access gate. + example: 'Failed to execute command: Failed to open property access gate.' '401': $ref: '#/components/responses/Unauthorized' '403': @@ -12284,6 +12553,213 @@ components: type: string format: date-time + EconomicTransferQueueStatus: + type: string + enum: + - QUEUED + - PROCESSING + - COMPLETED + - FAILED + + EconomicTransferQueueJob: + type: object + properties: + id: + type: integer + transfer_type: + type: string + enum: + - ORDER_DRAFT_EXPORT + - ORDER_INVOICE_EXPORT + - COLLECTED_INVOICE_EXPORT + status: + $ref: '#/components/schemas/EconomicTransferQueueStatus' + progress_percent: + type: integer + minimum: 0 + maximum: 100 + progress_message: + type: string + nullable: true + attempts: + type: integer + minimum: 0 + max_attempts: + type: integer + minimum: 1 + error_message: + type: string + nullable: true + payload: + type: object + nullable: true + additionalProperties: true + result: + type: object + nullable: true + additionalProperties: true + created_by: + type: integer + nullable: true + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + next_retry_at: + type: string + format: date-time + nullable: true + required: + - id + - transfer_type + - status + - progress_percent + - attempts + - max_attempts + + EconomicTransferQueueEnqueueResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job_id: + type: integer + minimum: 1 + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job_id + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueStatusResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/EconomicTransferQueueJob' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueRetryResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueListResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + count: + type: integer + minimum: 0 + required: + - items + - count + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + CollectedInvoiceEconomicCompareResponse: type: object description: Result of comparing a collected invoice with its E-conomic counterpart diff --git a/services/nginx/app/classes/economic_transfer_executor.php b/services/nginx/app/classes/economic_transfer_executor.php new file mode 100644 index 00000000..9de1da47 --- /dev/null +++ b/services/nginx/app/classes/economic_transfer_executor.php @@ -0,0 +1,432 @@ +getOrderById($order_id); + if (!$order->exists()) { + throw new Exception('Order not found'); + } + + $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'); + } + + $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); + + $billable_order_items = 0; + foreach ($order_items as $order_item) { + $added = $this->addOrderItemToInvoice( + $customer, + $order, + $order_item, + $economic_invoice_draft, + (int)($order_item['quantity'] ?? 1) + ); + if ($added) { + $billable_order_items++; + } + } + + if ($billable_order_items < 1) { + throw new Exception('No billable order items found'); + } + + $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); + } + + $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); + 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(); + } + + /** + * Export booked invoice from existing draft. + * @throws Exception + */ + public function exportOrderInvoice(int $order_id, int $user_id = 0): 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'); + } + + $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(); + } + + /** + * Export collected invoice to e-conomic. + * @throws Exception + */ + public function exportCollectedInvoice(int $collected_invoice_id, bool $send_as_is = false, int $user_id = 0): array + { + $collected_order_invoices = (new collected_order_invoices_o())->select($collected_invoice_id); + $collected_order_invoices->requireSelected(); + + if ($collected_order_invoices->external_id->value() === null) { + if (!$send_as_is) { + $customer_fixed_pricing_o = new customer_fixed_pricing_o(); + if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { + $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); + $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); + $collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price); + } else { + $collected_order_invoices->addVehicleSubscriptionsTransaction(); + } + } else { + $collected_order_invoices->removeSpecialArrangements(); + $collected_order_invoices->setAllItemsToBeIncludedInInvoice(); + } + + $collected_order_invoices->addToEconomic(); + } else { + if ($collected_order_invoices->booked_invoice_id->value() !== null) { + throw new Exception('Invoice has already been booked'); + } + if ($collected_order_invoices->isDraftExisting()) { + throw new Exception('Invoice draft already exists in E-Conomic'); + } + + $customer_fixed_pricing_o = new customer_fixed_pricing_o(); + if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { + $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); + $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); + $collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price); + } else { + $collected_order_invoices->addVehicleSubscriptionsTransaction(); + } + + $collected_order_invoices->addToEconomic(true); + } + + (new logs_o())->add( + 'orderInvoices', + 'global', + 1, + $user_id, + 'ADD_COLLECTED_INVOICE_ECONOMIC', + 'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id + ); + + return $collected_order_invoices->asArray(); + } + + /** + * @throws Exception + */ + public 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()); + } + } + } + + /** + * Returns true when a product line was added, false when skipped. + * @throws Exception + */ + public function addOrderItemToInvoice( + users_o $customer, + orders_o $order, + mixed $order_item, + economic_invoice_draft_mo $economic_invoice_draft, + int $quantity = 1 + ): bool { + if (self::shouldSkipOrderItemForInvoice($order_item, $quantity)) { + return false; + } + + if (!is_array($order_item)) { + throw new Exception('Order item payload must be an array'); + } + + $product_number = trim((string)($order_item['product']['economic_product_id'] ?? '')); + if ($product_number === '') { + throw new Exception('Order item is missing economic product id'); + } + + $product_name = trim((string)($order_item['product']['name'] ?? '')); + if ($product_name === '') { + $product_name = 'Ukendt produkt'; + } + + $reference = isset($order_item['reference']) ? (string)$order_item['reference'] : ''; + $notes = isset($order_item['notes']) ? (string)$order_item['notes'] : ''; + + $department = $order->getDepartmentByOrderId($order->id); + $economic_department_id = $department['economic_department_id']; + $economic_dimension_id = $department['economic_dimension_id'] ?? 0; + $order_item_price = (float)($order_item['price'] ?? 0); + $product_price = (float)($order_item['product']['price'] ?? 0); + + $economic_invoice_draft->addLine( + $product_number, + $product_name, + $quantity, + $order_item_price, + 0, + (int)$economic_department_id ?? 0, + (int)$economic_dimension_id ?? 0 + ); + + $show_discount = abs($order_item_price - $product_price) > 0.00001; + if ($show_discount && abs($product_price) > 0.00001) { + $discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 0); + $economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item_price - $product_price) . ' DKK (' . $discount_percentage . '%)'); + } + + if ($reference !== '') { + $economic_invoice_draft->addLineTEXT('Reference:'); + if (str_contains($reference, "\n")) { + foreach (explode("\n", $reference) as $line) { + $economic_invoice_draft->addLineTEXT('# ' . $line); + } + } else { + $economic_invoice_draft->addLineTEXT('# ' . $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 ($notes !== '') { + $economic_invoice_draft->addLineTEXT('Notat:'); + if (str_contains($notes, "\n")) { + foreach (explode("\n", $notes) as $line) { + $economic_invoice_draft->addLineTEXT('# ' . $line); + } + } else { + $economic_invoice_draft->addLineTEXT('# ' . $notes); + } + } + + return true; + } + + /** + * @throws Exception + */ + public function addOrderToInvoiceDraft(int $economic_invoice_draft_id, orders_o $order, users_o $customer, array $order_items): object + { + $has_billable_items = false; + foreach ($order_items as $order_item) { + if (!self::shouldSkipOrderItemForInvoice($order_item, (int)($order_item['quantity'] ?? 1))) { + $has_billable_items = true; + break; + } + } + if (!$has_billable_items) { + throw new Exception('No billable order items found'); + } + + $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); + } + + /** + * Skip item lines that are not billable in e-conomic export (0 quantity or 0 unit cost). + */ + public static function shouldSkipOrderItemForInvoice(mixed $order_item, int $quantity = 1): bool + { + if (!is_array($order_item)) { + return true; + } + + $line_quantity = $quantity; + if (isset($order_item['quantity']) && is_numeric($order_item['quantity'])) { + $line_quantity = (int)$order_item['quantity']; + } + + if ($line_quantity <= 0) { + return true; + } + + $line_price = null; + if (isset($order_item['price']) && is_numeric($order_item['price'])) { + $line_price = (float)$order_item['price']; + } + + if ($line_price === null) { + return true; + } + + if (abs($line_price) < 0.00001) { + return true; + } + + return false; + } +} diff --git a/services/nginx/app/classes/economic_transfer_queue.php b/services/nginx/app/classes/economic_transfer_queue.php new file mode 100644 index 00000000..553289d4 --- /dev/null +++ b/services/nginx/app/classes/economic_transfer_queue.php @@ -0,0 +1,480 @@ +executor = $executor ?? new economic_transfer_executor(); + economic_transfer_queue_schema_bootstrap::ensureTables(); + } + + /** + * @throws Exception + */ + public function enqueue(string $transfer_type, array $payload, int $created_by = 0, int $max_attempts = 3): array + { + global $db; + + $transfer_type = $this->validateTransferType($transfer_type); + $max_attempts = max(1, min(10, $max_attempts)); + $created_by = max(0, $created_by); + + $payload_json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($payload_json === false) { + throw new Exception('Failed to serialize queue payload'); + } + + $stmt = $db->prepare( + "INSERT INTO economic_transfer_queue_jobs + (transfer_type, payload_json, status, progress_percent, progress_message, attempts, max_attempts, created_by) + VALUES (?, ?, ?, 0, 'Queued', 0, ?, ?)" + ); + if (!$stmt) { + throw new Exception('Failed to prepare queue insert statement'); + } + + $status = self::STATUS_QUEUED; + $stmt->bind_param('sssii', $transfer_type, $payload_json, $status, $max_attempts, $created_by); + if (!$stmt->execute()) { + throw new Exception('Failed to enqueue transfer job'); + } + $job_id = (int)$db->insert_id(); + $stmt->close(); + + (new logs_o())->add( + 'economic_transfer_queue', + 'global', + 1, + $created_by, + 'ECONOMIC_TRANSFER_JOB_ENQUEUED', + 'Transfer job #' . $job_id . ' queued: ' . $transfer_type + ); + + $job = $this->getJobById($job_id); + if ($job === null) { + throw new Exception('Failed to load queued transfer job'); + } + return $job; + } + + public function getJobById(int $job_id): ?array + { + global $db; + + $stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? LIMIT 1"); + if (!$stmt) { + return null; + } + + $stmt->bind_param('i', $job_id); + if (!$stmt->execute()) { + $stmt->close(); + return null; + } + + $result = $stmt->get_result(); + $row = $result instanceof mysqli_result ? $result->fetch_assoc() : null; + $stmt->close(); + + if (!$row) { + return null; + } + return $this->normalizeJobRow($row); + } + + public function listJobs(array $statuses = [], int $limit = 50, int $offset = 0): array + { + global $db; + + $limit = max(1, min(500, $limit)); + $offset = max(0, $offset); + + $where = ''; + if (!empty($statuses)) { + $clean_statuses = array_values(array_filter(array_map(static function ($status): string { + return strtoupper(trim((string)$status)); + }, $statuses), static function ($status): bool { + return in_array($status, [ + self::STATUS_QUEUED, + self::STATUS_PROCESSING, + self::STATUS_COMPLETED, + self::STATUS_FAILED, + ], true); + })); + if (!empty($clean_statuses)) { + $escaped = array_map(static function ($status) use ($db): string { + return "'" . $db->escape_string($status) . "'"; + }, array_values(array_unique($clean_statuses))); + $where = 'WHERE status IN (' . implode(',', $escaped) . ')'; + } + } + + $sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset"; + $result = $db->query($sql); + if (!$result instanceof mysqli_result) { + return []; + } + + $jobs = []; + while ($row = $result->fetch_assoc()) { + $jobs[] = $this->normalizeJobRow($row); + } + return $jobs; + } + + /** + * @throws Exception + */ + public function retryJob(int $job_id): array + { + global $db; + + $existing_job = $this->getJobById($job_id); + if ($existing_job === null) { + throw new Exception('Queue job not found'); + } + if ((string)($existing_job['status'] ?? '') !== self::STATUS_FAILED) { + throw new Exception('Only failed jobs can be retried'); + } + if ((int)($existing_job['attempts'] ?? 0) >= (int)($existing_job['max_attempts'] ?? 1)) { + throw new Exception('Queue job reached max retry attempts'); + } + + $stmt = $db->prepare( + "UPDATE economic_transfer_queue_jobs + SET status = ?, progress_percent = 0, progress_message = 'Queued for retry', + error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL + WHERE id = ? AND status = ?" + ); + if (!$stmt) { + throw new Exception('Failed to prepare retry statement'); + } + + $queued = self::STATUS_QUEUED; + $failed = self::STATUS_FAILED; + $stmt->bind_param('sis', $queued, $job_id, $failed); + $stmt->execute(); + $affected = $stmt->affected_rows; + $stmt->close(); + + if ($affected < 1) { + throw new Exception('Failed to retry queue job'); + } + + $job = $this->getJobById($job_id); + if ($job === null) { + throw new Exception('Retry updated job could not be loaded'); + } + return $job; + } + + public function processPending(int $limit = 5): array + { + $limit = max(1, min(100, $limit)); + $this->releaseStaleProcessingLocks(); + + $processed = 0; + $completed = 0; + $failed = 0; + $jobs = []; + $empty_claims = 0; + + for ($i = 0; $i < $limit; $i++) { + $job = $this->claimNextJob(); + if ($job === null) { + $empty_claims++; + if ($empty_claims >= 3) { + break; + } + continue; + } + $empty_claims = 0; + + $processed++; + $jobs[] = $job['id']; + $this->updateProgress((int)$job['id'], 15, 'Running transfer'); + + try { + $result = $this->executeJob($job); + $this->markCompleted((int)$job['id'], $result); + $completed++; + } catch (Exception $e) { + $this->markFailed((int)$job['id'], $e->getMessage()); + $failed++; + } catch (\Throwable $e) { + $this->markFailed((int)$job['id'], $e->getMessage()); + $failed++; + } + } + + return [ + 'processed' => $processed, + 'completed' => $completed, + 'failed' => $failed, + 'jobs' => $jobs, + ]; + } + + private function claimNextJob(): ?array + { + global $db; + + $sql = "SELECT id FROM economic_transfer_queue_jobs + WHERE status = '" . self::STATUS_QUEUED . "' + AND attempts < max_attempts + AND (next_retry_at IS NULL OR next_retry_at <= NOW()) + ORDER BY id ASC + LIMIT 1"; + $result = $db->query($sql); + if (!$result instanceof mysqli_result) { + return null; + } + + $row = $result->fetch_assoc(); + if (!$row || !isset($row['id'])) { + return null; + } + + $job_id = (int)$row['id']; + $stmt = $db->prepare( + "UPDATE economic_transfer_queue_jobs + SET status = ?, progress_percent = 5, progress_message = 'Processing', started_at = NOW(), locked_at = NOW() + WHERE id = ? AND status = ?" + ); + if (!$stmt) { + return null; + } + + $processing = self::STATUS_PROCESSING; + $queued = self::STATUS_QUEUED; + $stmt->bind_param('sis', $processing, $job_id, $queued); + $stmt->execute(); + $affected = $stmt->affected_rows; + $stmt->close(); + + if ($affected < 1) { + return null; + } + + return $this->getJobById($job_id); + } + + /** + * @throws Exception + */ + private function executeJob(array $job): array + { + $payload = (array)($job['payload'] ?? []); + $transfer_type = (string)($job['transfer_type'] ?? ''); + $requested_by = (int)($payload['requested_by'] ?? ($job['created_by'] ?? 0)); + + $this->updateProgress((int)$job['id'], 40, 'Validating job payload'); + + return match ($transfer_type) { + self::TYPE_ORDER_DRAFT_EXPORT => $this->executeOrderDraftExport($job, $payload, $requested_by), + self::TYPE_ORDER_INVOICE_EXPORT => $this->executeOrderInvoiceExport($job, $payload, $requested_by), + self::TYPE_COLLECTED_INVOICE_EXPORT => $this->executeCollectedInvoiceExport($job, $payload, $requested_by), + default => throw new Exception('Unsupported transfer type: ' . $transfer_type), + }; + } + + /** + * @throws Exception + */ + private function executeOrderDraftExport(array $job, array $payload, int $requested_by): array + { + $order_id = (int)($payload['order_id'] ?? 0); + if ($order_id < 1) { + throw new Exception('order_id is required'); + } + $this->updateProgress((int)$job['id'], 65, 'Exporting order draft invoice'); + return $this->executor->exportOrderDraftInvoice($order_id, $requested_by); + } + + /** + * @throws Exception + */ + private function executeOrderInvoiceExport(array $job, array $payload, int $requested_by): array + { + $order_id = (int)($payload['order_id'] ?? 0); + if ($order_id < 1) { + throw new Exception('order_id is required'); + } + $this->updateProgress((int)$job['id'], 65, 'Exporting booked invoice'); + return $this->executor->exportOrderInvoice($order_id, $requested_by); + } + + /** + * @throws Exception + */ + private function executeCollectedInvoiceExport(array $job, array $payload, int $requested_by): array + { + $collected_invoice_id = (int)($payload['collected_invoice_id'] ?? 0); + if ($collected_invoice_id < 1) { + throw new Exception('collected_invoice_id is required'); + } + $send_as_is = (bool)($payload['send_as_is'] ?? false); + $this->updateProgress((int)$job['id'], 65, 'Exporting collected invoice'); + return $this->executor->exportCollectedInvoice($collected_invoice_id, $send_as_is, $requested_by); + } + + private function updateProgress(int $job_id, int $percent, string $message): void + { + global $db; + + $percent = max(0, min(100, $percent)); + $escaped_message = $db->escape_string($message); + $sql = "UPDATE economic_transfer_queue_jobs + SET progress_percent = $percent, progress_message = '$escaped_message' + WHERE id = $job_id"; + $db->query($sql); + } + + private function markCompleted(int $job_id, array $result): void + { + global $db; + + $result_json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($result_json === false) { + $result_json = json_encode(['result' => 'serialization_error']); + } + $escaped_result = $db->escape_string((string)$result_json); + + $sql = "UPDATE economic_transfer_queue_jobs + SET status = '" . self::STATUS_COMPLETED . "', + progress_percent = 100, + progress_message = 'Completed', + result_json = '$escaped_result', + error_message = NULL, + completed_at = NOW(), + locked_at = NULL + WHERE id = $job_id"; + $db->query($sql); + + (new logs_o())->add( + 'economic_transfer_queue', + 'global', + 1, + 0, + 'ECONOMIC_TRANSFER_JOB_COMPLETED', + 'Transfer job #' . $job_id . ' completed' + ); + } + + private function markFailed(int $job_id, string $error_message): void + { + global $db; + + $error_message = trim($error_message); + if ($error_message === '') { + $error_message = 'Unknown transfer queue error'; + } + $escaped_error = $db->escape_string($error_message); + + $sql = "UPDATE economic_transfer_queue_jobs + SET status = '" . self::STATUS_FAILED . "', + progress_message = 'Failed', + error_message = '$escaped_error', + attempts = attempts + 1, + completed_at = NOW(), + locked_at = NULL + WHERE id = $job_id"; + $db->query($sql); + + (new logs_o())->add( + 'economic_transfer_queue', + 'global', + 3, + 0, + 'ECONOMIC_TRANSFER_JOB_FAILED', + 'Transfer job #' . $job_id . ' failed: ' . $error_message + ); + } + + private function decodeJsonValue(mixed $json): mixed + { + if (!is_string($json) || trim($json) === '') { + return null; + } + $decoded = json_decode($json, true); + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } + + private function normalizeJobRow(array $row): array + { + return [ + 'id' => (int)$row['id'], + 'transfer_type' => (string)($row['transfer_type'] ?? ''), + 'status' => (string)($row['status'] ?? self::STATUS_QUEUED), + 'progress_percent' => (int)($row['progress_percent'] ?? 0), + 'progress_message' => $row['progress_message'] ?? null, + 'attempts' => (int)($row['attempts'] ?? 0), + 'max_attempts' => (int)($row['max_attempts'] ?? 0), + 'error_message' => $row['error_message'] ?? null, + 'payload' => $this->decodeJsonValue($row['payload_json'] ?? null), + 'result' => $this->decodeJsonValue($row['result_json'] ?? null), + 'created_by' => isset($row['created_by']) ? (int)$row['created_by'] : null, + 'created_at' => $row['created_at'] ?? null, + 'updated_at' => $row['updated_at'] ?? null, + 'started_at' => $row['started_at'] ?? null, + 'completed_at' => $row['completed_at'] ?? null, + 'next_retry_at' => $row['next_retry_at'] ?? null, + ]; + } + + /** + * Release jobs stuck in PROCESSING due to crashes or killed workers. + */ + private function releaseStaleProcessingLocks(): void + { + global $db; + + $timeout = (int)self::STALE_PROCESSING_LOCK_SECONDS; + $sql = "UPDATE economic_transfer_queue_jobs + SET status = '" . self::STATUS_QUEUED . "', + progress_message = 'Re-queued after stale processing lock', + locked_at = NULL, + started_at = NULL + WHERE status = '" . self::STATUS_PROCESSING . "' + AND locked_at IS NOT NULL + AND locked_at < (NOW() - INTERVAL $timeout SECOND)"; + $db->query($sql); + } + + /** + * @throws Exception + */ + private function validateTransferType(string $transfer_type): string + { + $transfer_type = strtoupper(trim($transfer_type)); + if (!in_array($transfer_type, [ + self::TYPE_ORDER_DRAFT_EXPORT, + self::TYPE_ORDER_INVOICE_EXPORT, + self::TYPE_COLLECTED_INVOICE_EXPORT, + ], true)) { + throw new Exception('Unsupported transfer type: ' . $transfer_type); + } + return $transfer_type; + } +} diff --git a/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php new file mode 100644 index 00000000..1c473a64 --- /dev/null +++ b/services/nginx/app/classes/economic_transfer_queue_schema_bootstrap.php @@ -0,0 +1,47 @@ +query( + "CREATE TABLE IF NOT EXISTS economic_transfer_queue_jobs ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + transfer_type VARCHAR(64) NOT NULL, + payload_json JSON NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'QUEUED', + progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0, + progress_message VARCHAR(255) NULL, + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 3, + error_message TEXT NULL, + result_json JSON NULL, + created_by INT NULL, + started_at DATETIME NULL, + completed_at DATETIME NULL, + next_retry_at DATETIME NULL, + locked_at DATETIME NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_economic_transfer_queue_jobs_status_created (status, created_at), + INDEX idx_economic_transfer_queue_jobs_next_retry (next_retry_at), + INDEX idx_economic_transfer_queue_jobs_transfer_type (transfer_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + self::$initialized = true; + } +} diff --git a/services/nginx/app/cli.php b/services/nginx/app/cli.php index 4f9b5a4d..de06d7e5 100644 --- a/services/nginx/app/cli.php +++ b/services/nginx/app/cli.php @@ -29,6 +29,10 @@ set_time_limit(10 * 60); // 10 minutes // Set the memory limit to 16 GB ini_set('memory_limit', '16G'); +require_once __DIR__ . '/classes/economic_transfer_executor.php'; +require_once __DIR__ . '/classes/economic_transfer_queue_schema_bootstrap.php'; +require_once __DIR__ . '/classes/economic_transfer_queue.php'; + // If the first argument is 'run', switch to the second argument if ($args[1] === 'run') { @@ -88,6 +92,12 @@ if ($args[1] === 'run') { case 'logSync': require_once 'cron/SyncLogs.php'; break; + case 'economic-transfer-queue': + echo "[" . date('Y-m-d H:i:s') . "][CRON] Running economic transfer queue worker\n"; + $queue = new \classes\economic_transfer_queue(); + $result = $queue->processPending(25); + echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"; + break; case 'cron': echo "[" . date('Y-m-d H:i:s') . "][CRON] Running the cron script\n"; require_once 'cron/Cron.php'; diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 440956ed..1970b9e1 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -3,6 +3,7 @@ use classes\backup_store; use classes\economic; +use classes\economic_transfer_queue; use classes\system_search_cache; use classes\system_search_document_index; use classes\system_search_economic_customer_index; @@ -25,6 +26,10 @@ use objects\logs_o; use objects\users_o; use routes\moduleWeatherAPIRoute; +require_once __DIR__ . '/../classes/economic_transfer_executor.php'; +require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php'; +require_once __DIR__ . '/../classes/economic_transfer_queue.php'; + if (!defined('WD')) { exit; } @@ -88,6 +93,12 @@ $cron_tasks = [ 'next_run' => 0, 'function' => 'SyncEconomicInvoiceStatus', ], + 'EconomicTransferQueueCron' => [ + 'interval' => 30, // 30 seconds + 'last_run' => 0, + 'next_run' => 0, + 'function' => 'EconomicTransferQueueCron', + ], 'SyncXLVaskModuleCron' => [ 'interval' => 3600, // 1 hour 'last_run' => 0, @@ -229,6 +240,22 @@ function SyncXLVaskModuleCron(): void } } +function EconomicTransferQueueCron(): void +{ + try { + $queue = new economic_transfer_queue(); + $result = $queue->processPending(10); + if ((int)($result['processed'] ?? 0) > 0) { + echo "[" . date('Y-m-d H:i:s') . "][CRON] EconomicTransferQueueCron processed=" + . (int)($result['processed'] ?? 0) + . " completed=" . (int)($result['completed'] ?? 0) + . " failed=" . (int)($result['failed'] ?? 0) . "\n"; + } + } catch (Throwable $e) { + warn('EconomicTransferQueueCron failed: ' . $e->getMessage()); + } +} + function SystemSearchCacheMaintenanceCron(): void { try { diff --git a/services/nginx/app/index.php b/services/nginx/app/index.php index 16486ab8..e716131b 100644 --- a/services/nginx/app/index.php +++ b/services/nginx/app/index.php @@ -70,14 +70,28 @@ try { */ spl_autoload_register(function (string $class): void { $class = ltrim($class, '\\'); + $cache_key = 'autoload:' . $class; + $is_loaded = static function (string $candidate): bool { + return class_exists($candidate, false) + || interface_exists($candidate, false) + || trait_exists($candidate, false) + || (function_exists('enum_exists') && enum_exists($candidate, false)); + }; // Check Redis cache first if (defined('redis')) { try { - $cached = redis->get('autoload:' . $class); - if ($cached && is_file($cached)) { + $cached = redis->get($cache_key); + if (is_string($cached) && $cached !== '' && is_file($cached)) { require_once $cached; - return; + if ($is_loaded($class)) { + return; + } + // Stale class mapping in cache, continue with normal lookup. + redis->delete($cache_key); + } elseif (is_string($cached) && $cached !== '') { + // Remove non-existing cached path to avoid repeated failed lookups. + redis->delete($cache_key); } } catch (\Throwable $e) { // Fall back to manual search on Redis error @@ -137,10 +151,10 @@ spl_autoload_register(function (string $class): void { $file = $path . $suffix . '.php'; if (is_file($file)) { require_once $file; - if (class_exists($class, false) || interface_exists($class, false) || trait_exists($class, false) || (function_exists('enum_exists') && enum_exists($class, false))) { + if ($is_loaded($class)) { if (defined('redis')) { try { - redis->setEx('autoload:' . $class, $file, 86400); // Cache for 24 hours + redis->setEx($cache_key, $file, 86400); // Cache for 24 hours } catch (\Throwable $e) {} } return; diff --git a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php index c1156559..76c99160 100644 --- a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php +++ b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php @@ -226,6 +226,9 @@ class economic_invoice_draft $total_discount = 0; // Loop through the order items foreach ( $order_items as $order_item ) { + if ($this->shouldSkipOrderItemLine($order_item)) { + continue; + } // Add the order item to the draft invoice self::addOrderItemLine($order_item, $department); // Add the line discount to the total discount @@ -254,6 +257,9 @@ class economic_invoice_draft if (!isset($order_item['id'])) { throw new Exception('The order item is not valid'); } + if ($this->shouldSkipOrderItemLine($order_item)) { + return; + } // Get the department id $economic_department_id = $department['economic_department_id'] ?? 0; // Get the dimension id @@ -309,6 +315,25 @@ class economic_invoice_draft } + /** + * Skip line when quantity is zero/negative, or final unit price is zero. + */ + private function shouldSkipOrderItemLine(array $order_item): bool + { + $quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity']) + ? (float)$order_item['quantity'] + : 0.0; + if ($quantity <= 0.0) { + return true; + } + + $price = isset($order_item['price']) && is_numeric($order_item['price']) + ? (float)$order_item['price'] + : 0.0; + + return abs($price) < 0.00001; + } + /** * Add a product line to the draft invoice * @note The lines won't be saved until the addLines() method is called. @@ -428,4 +453,4 @@ class economic_invoice_draft throw new Exception('The draft invoice data is not set'); } } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 5d4c2e1f..758b5ef1 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -1482,4 +1482,9 @@ class collected_order_invoices_o extends db $row = $result->fetch_assoc(); return (int)$row['count'] === 0; } + + public function clearCachedData(): void + { + $this->objectChanged(); + } } \ No newline at end of file diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 142bce60..b95e4fc5 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -1676,4 +1676,45 @@ class orders_o extends db } return $orders; } + + /** + * @throws Exception + */ + public function getOrdersWithRegistrationNumberInDateRange(string $registration_number, string $from_date, string $to_date): array + { + global /** @var db $db */ + $db; + // Validate the date range + $from_timestamp = strtotime($from_date); + $to_timestamp = strtotime($to_date); + if ($from_timestamp === false || $to_timestamp === false) { + throw new Exception('Invalid date range provided'); + } + if ($from_timestamp > $to_timestamp) { + throw new Exception('The start date cannot be after the end date'); + } + // Prepare the SQL query to find orders with the registration number in the date range + $reg = strtoupper(trim($registration_number)); + if ($reg === '') { + return []; + } + $reg = $db->escape_string($reg); + $from_date = $db->escape_string(date('Y-m-d H:i:s', $from_timestamp)); + $to_date = $db->escape_string(date('Y-m-d H:i:s', $to_timestamp)); + $sql = "SELECT id FROM $this->table + WHERE (UPPER(TRIM(reg_1)) = '$reg' OR UPPER(TRIM(reg_2)) = '$reg' OR UPPER(TRIM(reg_3)) = '$reg') + AND created_at BETWEEN '$from_date' AND '$to_date' + AND deleted_at IS NULL"; + $result = $db->query($sql); + if ($result->num_rows === 0) { + return []; // No orders found with the registration number in the date range + } + $orders = []; + while ($row = $result->fetch_assoc()) { + $order = new orders_o(); + $order->select((int)$row['id']); + $orders[] = $order; + } + return $orders; + } } diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml new file mode 100644 index 00000000..d66e21dc --- /dev/null +++ b/services/nginx/app/openapi.yaml @@ -0,0 +1,16291 @@ +openapi: 3.0.3 +info: + title: Copenhagen Truck Wash API + description: | + This API provides access to the Copenhagen Truck Wash system, managing orders, bookings, + departments, products, customers, and various integrations including e-conomic, Stripe, + XLVask, and more. + + ## Authentication + Most endpoints require authentication using a Bearer token obtained from the `/auth/login` + or `/auth/employee/login` endpoints. + + ## Permissions + Many endpoints require specific permissions that are assigned to user groups/roles. + + ## Subusers and customer targeting + When authenticated as a subuser, most customer-scoped endpoints require an explicit target + customer context. Provide the header `X-Customer-Number: ` to target a + specific customer. If omitted, the API attempts to infer the customer from the authenticated + user context when possible. Classic user sessions ignore this header. + version: 1.0.0 + contact: + name: Copenhagen Truck Wash + email: support@truckwash.dk +servers: + - url: https://api.truckwash.dk + description: Production server (.dk) + - url: https://api.truckwash.io + description: Production server (.io) + - url: http://localhost/api + description: Local development server + +security: + - BearerAuth: [] + +tags: + - name: Authentication + description: User and employee authentication endpoints + - name: Security + description: Account security and passkey management endpoints + - name: Users + description: User management and customer operations + - name: Search + description: System-wide search endpoints + - name: Orders + description: Order creation, management, and retrieval + - name: Order Items + description: Managing items within orders + - name: Bookings + description: Booking management for wash services + - name: Departments + description: Department and location management + - name: Products + description: Product catalog and pricing + - name: Categories + description: Product category management + - name: Invoices + description: Invoice generation and management + - name: Payments + description: Payment processing and collection + - name: Vehicles + description: Vehicle registration and management + - name: Notifications + description: System notifications and alerts + - name: Statistics + description: Business analytics and reporting + - name: Modules + description: Third-party integrations and modules + - name: Attachments + description: File upload and attachment management + - name: Forms + description: Form submissions and management + - name: Worker + description: System worker status and maintenance + - name: Plate Scans + description: License plate scanning operations + - name: Config + description: Module configuration management + - name: Branding + description: Branding options management + - name: Roles + description: Role and permission management + - name: Self-Serve + description: Self-serve lane operations and questions + - name: Goals + description: Department goals management + - name: Subusers + description: Subuser registration and setup + - name: Bird + description: Voice Calls via Bird + +paths: + # Bird Voice Calls + /bird/voice/calls: + post: + tags: + - Bird + summary: Create/place a voice call via Bird + operationId: birdCreateVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallCreateRequest' + responses: + '200': + description: Call created + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + get: + tags: + - Bird + summary: List voice calls + operationId: birdListVoiceCalls + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: status + schema: + type: string + - in: query + name: type + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: direction + schema: + type: string + - in: query + name: id + schema: + type: string + format: uuid + - in: query + name: tag + schema: + oneOf: + - type: string + - type: array + items: + type: string + responses: + '200': + description: A list of calls + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallListResponse' } + + /bird/voice/calls/log: + get: + tags: + - Bird + summary: List workspace call log entries + operationId: birdListVoiceCallsLog + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: channelId + schema: + oneOf: + - type: string + - type: array + items: + type: string + format: uuid + - in: query + name: status + schema: + type: string + - in: query + name: type + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: direction + schema: + type: string + - in: query + name: id + schema: + type: string + format: uuid + - in: query + name: tag + schema: + oneOf: + - type: string + - type: array + items: + type: string + responses: + '200': + description: Workspace call log entries + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallsLogResponse' } + + /bird/voice/calls/{id}: + get: + tags: + - Bird + summary: Get a voice call by ID + operationId: birdGetVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Call details + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + patch: + tags: + - Bird + summary: Update a voice call by ID + operationId: birdUpdateVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallUpdateRequest' + responses: + '200': + description: Call update accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallSingleResponse' } + + /bird/voice/calls/{id}/answer: + post: + tags: + - Bird + summary: Answer an incoming voice call by ID + operationId: birdAnswerVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallAnswerRequest' + responses: + '200': + description: Answer command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/ringing: + post: + tags: + - Bird + summary: Mark voice call as ringing by ID + operationId: birdRingingVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRingingRequest' + responses: + '200': + description: Ringing command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/hangup: + post: + tags: + - Bird + summary: Hang up a voice call by ID + operationId: birdHangupVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallHangupRequest' + responses: + '200': + description: Hangup requested + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/playback: + post: + tags: + - Bird + summary: Playback media on a voice call by ID + operationId: birdPlaybackVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallPlaybackRequest' + responses: + '200': + description: Playback command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/say: + post: + tags: + - Bird + summary: Say a message on an active voice call and optionally hang up + operationId: birdSayOnVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallSayRequest' + responses: + '200': + description: Say command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/gather: + post: + tags: + - Bird + summary: Gather input from a voice call by ID + operationId: birdGatherVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallGatherRequest' + responses: + '200': + description: Gather command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/bridge: + post: + tags: + - Bird + summary: Bridge a voice call by ID + operationId: birdBridgeVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallBridgeRequest' + responses: + '200': + description: Bridge command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallBridgeResponse' } + + /bird/voice/calls/{id}/record: + post: + tags: + - Bird + summary: Record call audio by ID + operationId: birdRecordVoiceCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordRequest' + responses: + '200': + description: Record command accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallCommandResponse' } + + /bird/voice/calls/{id}/recordings: + post: + tags: + - Bird + summary: Create a call recording session + operationId: birdCreateVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordingCreateRequest' + responses: + '200': + description: Recording session created + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + get: + tags: + - Bird + summary: List call recordings for a voice call + operationId: birdListVoiceCallRecordings + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + responses: + '200': + description: List of call recordings + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingListResponse' } + + /bird/voice/calls/{id}/recordings/{recordingId}: + get: + tags: + - Bird + summary: Get a single call recording + operationId: birdGetVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: recordingId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Call recording details + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + patch: + tags: + - Bird + summary: Update call recording state + operationId: birdUpdateVoiceCallRecording + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: recordingId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdVoiceCallRecordingUpdateRequest' + responses: + '200': + description: Recording update accepted + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallRecordingSingleResponse' } + + /bird/voice/calls/{id}/insights: + get: + tags: + - Bird + summary: Get voice call insights + operationId: birdGetVoiceCallInsights + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Voice call insights + content: + application/json: + schema: { $ref: '#/components/schemas/BirdVoiceCallInsightsResponse' } + + /bird/voice/calls/test-outbound: + post: + tags: + - Bird + summary: Place a test outbound call and hang up when accepted + operationId: birdTestOutboundVoiceCall + description: Calls +45 42 33 11 28 and hangs up when the call reaches accepted/ongoing state. + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdTestOutboundCallRequest' + responses: + '200': + description: Test call created and either hung up or timed out + content: + application/json: + schema: { $ref: '#/components/schemas/BirdTestOutboundCallResponse' } + + /bird/voice/calls/webhook/inbound: + post: + tags: + - Bird + summary: Process inbound Bird voice call lifecycle + operationId: birdInboundVoiceCallWebhook + description: > + Stateful inbound-call webhook that answers the call immediately, tracks DTMF input, and enforces a timeout hangup flow. + During the first 300 seconds from call start, DTMF input is extracted from fixed payload fields + (`dtmf`, `digit`, `digits`, `keys`, and known nested variants). A Slack message is sent whenever any DTMF + value is captured, the call acknowledges input, and gather is re-issued with retry-loop semantics + checking for input every 2 seconds until a digit is entered. At or after 300 seconds, the webhook + says `timeout reached`, waits 10 + seconds, sends a hangup command once, and polls Bird call status until terminal. Call lifecycle + state is only finalized and cleared after terminal status is observed. + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (fallbacks to payload/state/module configuration) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (fallbacks to payload/state/module configuration) + - in: query + name: callId + schema: + type: string + required: false + description: Bird Call identifier (fallbacks to payload fields) + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/BirdInboundCallWebhookRequest' + responses: + '200': + description: Lifecycle phase result for this webhook invocation + content: + application/json: + schema: + $ref: '#/components/schemas/BirdInboundCallWebhookResponse' + + # Bird Numbers + /bird/numbers: + get: + tags: + - Bird + summary: List your numbers + operationId: birdListNumbers + parameters: + - in: query + name: workspaceId + required: false + schema: + type: string + format: uuid + description: Bird Workspace identifier (optional if configured) + - in: query + name: page + required: false + schema: + type: integer + - in: query + name: limit + required: false + schema: + type: integer + responses: + '200': + description: A list of numbers + content: + application/json: + schema: + $ref: '#/components/schemas/BirdNumberListResponse' + + /bird/numbers/{id}: + get: + tags: + - Bird + summary: Get a number by ID + operationId: birdGetNumber + parameters: + - in: query + name: workspaceId + required: false + schema: + type: string + format: uuid + description: Bird Workspace identifier (optional if configured) + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: Number details + content: + application/json: + schema: + $ref: '#/components/schemas/BirdNumberSingleResponse' + delete: + tags: + - Bird + summary: Delete/release a number by ID + operationId: birdDeleteNumber + parameters: + - in: query + name: workspaceId + required: false + schema: + type: string + format: uuid + description: Bird Workspace identifier (optional if configured) + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: Number deletion/release accepted + content: + application/json: + schema: + type: object + additionalProperties: true + + # Bird Voice Flash Calling + /bird/voice/flash-calls: + post: + tags: + - Bird + summary: Create/place a flash call via Bird + operationId: birdCreateFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallCreateRequest' + responses: + '200': + description: Flash call created + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallSingleResponse' + get: + tags: + - Bird + summary: List flash calls + operationId: birdListFlashCalls + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + description: Bird Workspace identifier (falls back to module configuration if omitted) + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + description: Bird Channel identifier (falls back to module configuration if omitted) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 1000 + - in: query + name: pageToken + schema: + type: string + - in: query + name: startAt + schema: + type: string + format: date-time + - in: query + name: endAt + schema: + type: string + format: date-time + - in: query + name: status + schema: + type: string + - in: query + name: to + schema: + type: string + - in: query + name: from + schema: + type: string + - in: query + name: duration + schema: + type: integer + - in: query + name: id + schema: + type: string + format: uuid + responses: + '200': + description: A list of flash calls + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallListResponse' + + /bird/voice/flash-calls/{id}: + get: + tags: + - Bird + summary: Get a flash call by ID + operationId: birdGetFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Flash call details + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallSingleResponse' + post: + tags: + - Bird + summary: Complete/end a flash call by ID + operationId: birdEndFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + - in: path + name: id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallEndRequest' + responses: + '200': + description: Flash call completed + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallSingleResponse' + + /bird/voice/flash-calls/hangup: + post: + tags: + - Bird + summary: Hang up flash calls using payload criteria + operationId: birdHangupFlashCall + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupRequest' + responses: + '200': + description: Flash call hangup accepted + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupResponse' + + /bird/voice/flash-calls/end: + post: + tags: + - Bird + summary: Compatibility alias for flash hangup endpoint + description: Deprecated alias for `/bird/voice/flash-calls/hangup`. + deprecated: true + operationId: birdEndFlashCallByNumbers + parameters: + - in: query + name: workspaceId + schema: + type: string + format: uuid + required: false + - in: query + name: channelId + schema: + type: string + format: uuid + required: false + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupRequest' + responses: + '200': + description: Flash call hangup accepted (alias) + content: + application/json: + schema: + $ref: '#/components/schemas/BirdFlashCallHangupResponse' + + # Subusers (public registration + setup) + /subusers: + get: + tags: + - Subusers + summary: List subusers visible to the authenticated user + description: | + Returns a paginated list of subusers (drivers) that have enabled grants tied to the + authenticated user's customer number. Only subusers with at least one enabled, non-deleted + grant for the caller's customer are returned. + operationId: listSubusers + parameters: + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 1000 + - name: search + in: query + required: false + schema: + type: string + - name: include_non_enabled + in: query + required: false + description: Include subusers that only have non-enabled grants (default false) + schema: + type: boolean + responses: + '200': + description: List of visible subusers + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + username: + type: string + nullable: true + name: + type: string + nullable: true + email: + type: string + format: email + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: integer + nullable: true + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + suspended_at: + type: string + format: date-time + nullable: true + two_factor_enabled: + type: boolean + description: Indicates if 2FA is enabled for this account + permissions: + type: array + description: Aggregated permission keys granted for the caller's customer + items: + type: string + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/me: + get: + tags: + - Subusers + summary: Get current subuser profile + description: | + Returns the authenticated subuser (driver) profile and their enabled grants grouped by + `billing_customer_number`. + + Notes: + - This endpoint is available only to authenticated subuser sessions. + - It does not require the `X-Customer-Number` header; all enabled, non-deleted grants for the + subuser are included in the response. + operationId: getCurrentSubuser + responses: + '200': + description: Current subuser details + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserSelf' + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + tags: + - Subusers + summary: Create a subuser registration + description: | + Creates a subuser (driver) account using a company's CVR and a phone number. Validates the + CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled + sends a setup link by SMS for the user to complete registration. + operationId: createSubuser + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - cvr + - phone_country_code + - phone + properties: + cvr: + type: integer + description: Danish CVR (8 digits) + example: 12345678 + phone_country_code: + type: integer + description: Phone country code (1–3 digits) + example: 45 + phone: + type: integer + description: Phone number (4–15 digits, no leading +) + example: 12345678 + responses: + '200': + description: Subuser created (or pending setup) and company identified + content: + application/json: + schema: + type: object + properties: + cvr: + type: integer + example: 12345678 + customer_number: + type: integer + description: Matched e-conomic customer number + example: 1000 + '400': { $ref: '#/components/responses/BadRequest' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/{id}: + get: + tags: + - Subusers + summary: Get a subuser by ID (visible by grant) + description: | + Returns the subuser if the authenticated user has at least one enabled, non-deleted grant + for their customer number to this subuser. Otherwise returns 404. + operationId: getSubuser + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Subuser details + content: + application/json: + schema: + type: object + properties: + id: + type: integer + username: + type: string + nullable: true + name: + type: string + nullable: true + email: + type: string + format: email + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: integer + nullable: true + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + suspended_at: + type: string + format: date-time + nullable: true + permissions: + type: array + description: Aggregated permission keys granted for the caller's customer + items: + type: string + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/setup: + get: + tags: + - Subusers + summary: Validate setup token + description: Validates a subuser setup token generated during registration. + operationId: validateSubuserSetupToken + security: [] + parameters: + - name: token + in: query + required: true + schema: + type: string + description: One-time setup token received via SMS + responses: + '200': + description: Token is valid + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Token is valid + subuser_id: + type: integer + example: 42 + '400': { $ref: '#/components/responses/BadRequest' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + tags: + - Subusers + summary: Complete subuser setup + description: | + Completes subuser setup by setting a password and basic profile fields. Accepts optional + `username` and `email`. + operationId: completeSubuserSetup + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - token + - password + - name + properties: + token: + type: string + description: One-time setup token + password: + type: string + format: password + minLength: 8 + description: Must include at least one uppercase letter, one lowercase letter, and one number + pattern: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$' + name: + type: string + minLength: 3 + maxLength: 255 + username: + type: string + minLength: 3 + maxLength: 255 + email: + type: string + format: email + minLength: 3 + maxLength: 255 + responses: + '200': + description: Setup completed + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Password set successfully + '400': { $ref: '#/components/responses/BadRequest' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/auth/password: + post: + tags: + - Subusers + summary: Authenticate subuser with password + description: | + Authenticates a subuser (driver) using a password together with one of the supported + identifiers: `phone_country_code` + `phone`, `subuser_id`, or `username`. + + On success, returns a newly generated session token for the subuser. + operationId: subuserPasswordAuth + security: [] + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: object + required: [phone_country_code, phone, password] + properties: + phone_country_code: + type: integer + description: Phone country code (1–3 digits) + minimum: 1 + maximum: 999 + example: 45 + phone: + type: integer + description: Phone number (4–15 digits, no leading +) + minimum: 1000 + maximum: 999999999999999 + example: 12345678 + password: + type: string + format: password + minLength: 8 + maxLength: 255 + - type: object + required: [subuser_id, password] + properties: + subuser_id: + type: integer + description: Subuser ID + example: 42 + password: + type: string + format: password + minLength: 8 + maxLength: 255 + - type: object + required: [username, password] + properties: + username: + type: string + minLength: 3 + maxLength: 255 + example: jdoe + password: + type: string + format: password + minLength: 8 + maxLength: 255 + examples: + withPhone: + summary: Authenticate with phone + value: + phone_country_code: 45 + phone: 12345678 + password: MySecureP@ssw0rd + withSubuserId: + summary: Authenticate with subuser_id + value: + subuser_id: 42 + password: MySecureP@ssw0rd + withUsername: + summary: Authenticate with username + value: + username: jdoe + password: MySecureP@ssw0rd + responses: + '200': + description: Authentication successful + content: + application/json: + schema: + oneOf: + - type: object + required: [session] + properties: + session: + type: string + description: Newly generated subuser session token + example: "2f7a8c0e-9b1d-4c6a-91a9-1a2b3c4d5e6f" + - type: object + required: [2fa_required, 2fa_token] + properties: + 2fa_required: + type: boolean + example: true + 2fa_token: + type: string + description: Temporary 2FA verification token + example: "557a3e7b1a2b..." + '400': { $ref: '#/components/responses/BadRequest' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/grants: + get: + tags: + - Subusers + summary: List subuser grants + description: Returns subuser grant records filtered by `customer_number` and/or `subuser_id`. + operationId: listSubuserGrants + security: + - BearerAuth: [] + parameters: + - name: customer_number + in: query + required: false + schema: + type: integer + description: e-conomic customer number to filter by + - name: subuser_id + in: query + required: false + schema: + type: integer + description: Subuser ID to filter by + responses: + '200': + description: Grants fetched + content: + application/json: + schema: + type: object + properties: + grants: + type: array + items: + $ref: '#/components/schemas/SubuserGrant' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + tags: + - Subusers + summary: Create subuser grant + operationId: createSubuserGrant + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserGrantCreateRequest' + examples: + default: + value: + customer_number: 1000 + subuser_id: 42 + enabled: true + note: "Grant for bookings access" + permissions: ["BOOKINGS_LIST", "BOOKINGS_ADD", "BOOKINGS_EDIT"] + responses: + '200': + description: Grant created + content: + application/json: + schema: + type: object + properties: + grant: + $ref: '#/components/schemas/SubuserGrant' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/grants/{id}: + patch: + tags: + - Subusers + summary: Update subuser grant + operationId: updateSubuserGrant + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserGrantUpdateRequest' + examples: + enableOnly: + value: + enabled: true + updatePermissions: + value: + permissions: ["VEHICLES_LIST", "SELFSERVE_ADD"] + responses: + '200': + description: Grant updated + content: + application/json: + schema: + type: object + properties: + grant: + $ref: '#/components/schemas/SubuserGrant' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + delete: + tags: + - Subusers + summary: Delete subuser grant + operationId: deleteSubuserGrant + security: + - BearerAuth: [] + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Grant deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Grant deleted + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/permission-nodes: + get: + tags: + - Subusers + summary: List available subuser permission nodes + description: Returns grouped permission nodes available for subuser grants. + operationId: listSubuserPermissionNodes + security: + - BearerAuth: [] + responses: + '200': + description: Permission nodes fetched + content: + application/json: + schema: + type: object + properties: + permission_nodes: + type: array + items: + $ref: '#/components/schemas/PermissionNodeGroup' + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + + # Authentication Endpoints + /auth/login: + post: + tags: + - Authentication + summary: Customer login + description: Authenticate a customer using customer number and password + operationId: customerLogin + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - customer_number + - password + - g_recaptcha_response + properties: + customer_number: + type: integer + description: Customer's e-conomic customer number + example: 12345 + password: + type: string + format: password + description: Customer password + minLength: 1 + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Login successful + content: + application/json: + schema: + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer authentication token + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + - type: object + required: [2fa_required, 2fa_token] + properties: + 2fa_required: + type: boolean + example: true + 2fa_token: + type: string + description: Temporary 2FA verification token + example: "557a3e7b1a2b..." + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/employee/login: + post: + tags: + - Authentication + summary: Employee login + description: Authenticate an employee using user ID and password + operationId: employeeLogin + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - user_id + - password + - g_recaptcha_response + properties: + user_id: + type: integer + description: Employee user ID + example: 1 + password: + type: string + format: password + description: Employee password + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Login successful + content: + application/json: + schema: + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer authentication token + - type: object + required: [2fa_required, 2fa_token] + properties: + 2fa_required: + type: boolean + example: true + 2fa_token: + type: string + description: Temporary 2FA verification token + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/passkey/challenge: + post: + tags: + - Authentication + summary: Initiate passkey authentication challenge + description: Generates a WebAuthn PublicKeyCredentialRequestOptions payload. If customer_number is provided, allowCredentials will be populated with existing passkeys for that account. Otherwise, a challenge is issued for discoverable credentials. + operationId: passkeyChallenge + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - g_recaptcha_response + properties: + customer_number: + type: integer + description: Optional customer's e-conomic customer number + example: 12345 + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Challenge generated + content: + application/json: + schema: + type: object + properties: + challenge_token: + type: string + description: Temporary token binding the challenge to the login attempt + publicKey: + type: object + properties: + challenge: + type: string + description: Base64URL-encoded challenge + rpId: + type: string + description: Relying party ID (truckwash.io or localhost) + example: truckwash.io + timeout: + type: integer + description: Timeout in milliseconds + userVerification: + type: string + enum: [required, preferred, discouraged] + allowCredentials: + type: array + items: + type: object + properties: + type: + type: string + example: public-key + id: + type: string + description: Base64URL-encoded credential ID + transports: + type: array + items: + type: string + '400': + $ref: '#/components/responses/BadRequest' + + /auth/passkey/verify: + post: + tags: + - Authentication + summary: Verify passkey authentication and start session + description: Verifies the WebAuthn assertion and challenge token. Returns a session token on success. + operationId: passkeyVerify + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - challenge_token + - credential + - g_recaptcha_response + properties: + challenge_token: + type: string + description: The token returned by the challenge endpoint + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + credential: + type: object + description: The WebAuthn PublicKeyCredential object (assertion) + required: + - id + - rawId + - type + - response + properties: + id: + type: string + description: The credential ID (base64url) + rawId: + type: string + description: The raw credential ID (base64url) + type: + type: string + example: public-key + clientExtensionResults: + type: object + response: + type: object + required: + - clientDataJSON + - authenticatorData + - signature + properties: + clientDataJSON: + type: string + description: Base64URL-encoded client data + authenticatorData: + type: string + description: Base64URL-encoded authenticator data + signature: + type: string + description: Base64URL-encoded signature + userHandle: + type: string + nullable: true + description: Base64URL-encoded user handle + responses: + '200': + description: Verification successful, session started + content: + application/json: + schema: + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer token for customer + - type: object + required: [session] + properties: + session: + type: string + description: Session token for subuser + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/logout: + get: + tags: + - Authentication + summary: Logout + description: Invalidate the current authentication token + operationId: logout + responses: + '200': + description: Logout successful + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: "Logged out" + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/session: + get: + tags: + - Authentication + summary: Get current session + description: Retrieve information about the current authenticated user session + operationId: getSession + responses: + '200': + description: Session information retrieved successfully + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + two_factor_enabled: + type: boolean + description: Indicates if 2FA is enabled for this account + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/setup: + post: + tags: + - Authentication + summary: Generate 2FA secret + description: Generate a new TOTP secret for the authenticated user/subuser + operationId: setup2fa + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: 2FA secret generated successfully + content: + application/json: + schema: + type: object + properties: + secret: + type: string + description: The base32 encoded TOTP secret + qr_code_url: + type: string + description: An otpauth URL for generating a QR code + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/enable: + post: + tags: + - Authentication + summary: Enable 2FA + description: Verify a code and enable 2FA for the authenticated user/subuser + operationId: enable2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: + type: string + description: The 6-digit TOTP code + responses: + '200': + description: 2FA enabled successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/disable: + post: + tags: + - Authentication + summary: Disable 2FA + description: Verify a code and disable 2FA for the authenticated user/subuser + operationId: disable2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: + type: string + description: The 6-digit TOTP code + responses: + '200': + description: 2FA disabled successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/2fa/verify: + post: + tags: + - Authentication + summary: Verify 2FA code during login + description: Complete the login process by verifying the 2FA code + operationId: verify2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [2fa_token, code] + properties: + 2fa_token: + type: string + description: The temporary 2FA verification token + code: + type: string + description: The 6-digit TOTP code + responses: + '200': + description: Login successful + content: + application/json: + schema: + oneOf: + - type: object + required: [token] + properties: + token: + type: string + description: Bearer authentication token (for users/employees) + - type: object + required: [session] + properties: + session: + type: string + description: Session token (for subusers) + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/reCAPTCHA/public: + get: + tags: + - Authentication + summary: Get reCAPTCHA configuration + description: Retrieve public reCAPTCHA configuration for login forms + operationId: getRecaptchaConfig + security: [] + responses: + '200': + description: reCAPTCHA configuration retrieved successfully + content: + application/json: + schema: + type: object + properties: + rate_limit: + type: object + properties: + enabled: + type: boolean + limit: + type: integer + remaining: + type: integer + reset: + type: integer + warning: + type: string + nullable: true + recaptcha: + type: object + + /auth/register/cvr: + post: + tags: + - Authentication + summary: Register new customer by CVR + description: Register a new customer account using Danish CVR number + operationId: registerCustomerByCvr + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - cvr + - companyPhone + - invoiceEmail + - contactEmail + - contactPhone + - contactName + - g_recaptcha_response + properties: + cvr: + type: string + description: Danish CVR number + minLength: 8 + maxLength: 20 + example: "44794780" + companyPhone: + type: integer + description: Company phone number + minimum: 10000000 + maximum: 9999999999 + example: 21754690 + invoiceEmail: + type: string + format: email + description: Email for invoices + minLength: 5 + maxLength: 255 + example: "invoice@company.dk" + contactEmail: + type: string + format: email + description: Contact email + minLength: 5 + maxLength: 255 + example: "contact@company.dk" + contactPhone: + type: integer + description: Contact phone number + minimum: 10000000 + maximum: 9999999999 + example: 21754690 + contactName: + type: string + description: Contact person name + example: "Mikkel" + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '201': + description: Customer registered successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /auth/password-reset/request: + post: + tags: + - Authentication + summary: Request a customer password reset email + description: Send an email with a password reset token to the customer's email address + operationId: requestPasswordReset + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - customer_number + - g_recaptcha_response + properties: + customer_number: + type: integer + description: The customer number + example: 123456 + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Request processed + content: + application/json: + schema: + type: object + properties: + message: + type: string + '400': + $ref: '#/components/responses/BadRequest' + + /auth/password-reset/validate: + get: + tags: + - Authentication + summary: Validate a customer password reset key + description: Check if a password reset token is valid and hasn't expired + operationId: validatePasswordResetToken + security: [] + parameters: + - name: token + in: query + required: true + schema: + type: string + description: The password reset token + responses: + '200': + description: Token is valid + content: + application/json: + schema: + type: object + properties: + valid: + type: boolean + customer_id: + type: integer + '404': + description: Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /auth/password-reset/set: + post: + tags: + - Authentication + summary: Set a customer password using a reset key + description: Update the customer password using a valid reset token + operationId: setPasswordUsingResetToken + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - token + - password + - g_recaptcha_response + properties: + token: + type: string + description: The password reset token + password: + type: string + description: The new password + g_recaptcha_response: + type: string + description: reCAPTCHA verification token + responses: + '200': + description: Password updated successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + '400': + $ref: '#/components/responses/BadRequest' + '404': + description: Invalid or expired token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /su/intimidate: + post: + tags: + - Authentication + summary: Intimidate a user + description: Create an authentication token for another user (Superuser only) + operationId: suIntimidate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [user_id] + properties: + user_id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + token: {type: string} + + # User Endpoints + /users: + get: + tags: + - Users + summary: List users + description: Retrieve a paginated list of users + operationId: listUsers + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Users retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Users + summary: Create new user + description: Create a new user account + operationId: createUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreate' + responses: + '201': + description: User created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + put: + tags: + - Users + summary: Update user + description: Update an existing user + operationId: updateUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserUpdate' + responses: + '200': + description: User updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /users/customer: + get: + tags: + - Users + summary: Get customer details + description: Get details about a specific customer + operationId: getCustomer + parameters: + - name: customer_number + in: query + schema: + type: integer + responses: + '200': + description: Customer retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + $ref: '#/components/responses/NotFound' + + /superuser/user: + get: + tags: + - Users + summary: Get user by ID (superuser) + description: Get detailed user information by user ID + operationId: getSuperuserUser + parameters: + - name: user_id + in: query + schema: + type: integer + responses: + '200': + description: User retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + $ref: '#/components/responses/NotFound' + + /admin/customer/code: + get: + tags: + - Users + summary: Get customer code + operationId: getCustomerCode + parameters: + - name: customer_number + in: query + schema: {type: integer} + - name: user_id + in: query + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer code + operationId: addCustomerCode + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + customer_number: {type: integer} + user_id: {type: integer} + code: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /customer/department/default: + get: + tags: + - Users + summary: Get customer default department + operationId: getCustomerDefaultDepartment + parameters: + - name: customer_number + in: query + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer default department + operationId: addCustomerDefaultDepartment + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department] + properties: + customer_number: {type: integer} + department: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + delete: + tags: + - Users + summary: Delete customer default department + operationId: deleteCustomerDefaultDepartment + parameters: + - name: customer_number + in: query + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /customer/pricing/fixed: + get: + tags: + - Users + summary: Get customer fixed pricing + operationId: getCustomerFixedPricing + parameters: + - name: customer_number + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer fixed pricing + operationId: addCustomerFixedPricing + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customer_number, price, description] + properties: + customer_number: {type: integer} + price: {type: integer} + description: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + delete: + tags: + - Users + summary: Delete customer fixed pricing + operationId: deleteCustomerFixedPricing + parameters: + - name: customer_number + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /account/notifications: + put: + tags: + - Users + summary: Update user notification settings + operationId: updateUserNotifications + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + wash_certificate_email: {type: string} + sms_notifications_enabled: {type: boolean} + email_notifications_enabled: {type: boolean} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /user/permissions: + get: + tags: + - Users + summary: Get user permissions + operationId: getUserPermissions + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /customers: + get: + tags: + - Users + summary: List customers + operationId: listCustomers + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - name: barred + in: query + required: false + description: Optional e-conomic barred customer filter. + schema: + type: string + enum: ['true', 'false', 'barred', 'active', '1', '0'] + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/user/discounts: + get: + tags: + - Users + summary: Get user discounts + operationId: getUserDiscounts + parameters: + - name: user_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Set user discount + operationId: setUserDiscount + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [discount, object_id, is_category] + properties: + user_id: {type: integer} + discount: {type: integer} + object_id: {type: string} + is_category: {type: boolean} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/user/keys: + get: + tags: + - Users + summary: Get user keys + operationId: getUserKeys + parameters: + - name: user_id + in: query + required: true + schema: {type: integer} + - name: key + in: query + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Set user key + operationId: setUserKey + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [key, value] + properties: + user_id: {type: integer} + key: {type: string} + value: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/user/password: + post: + tags: + - Users + summary: Set user password + operationId: setUserPassword + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + user_id: {type: integer} + password: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/customer/getUserId: + get: + tags: + - Users + summary: Get user ID from customer number + description: Convert e-conomic customer number to internal user ID + operationId: getUserIdFromCustomerNumber + parameters: + - name: customer_number + in: query + required: true + schema: + type: integer + responses: + '200': + description: User ID retrieved successfully + content: + application/json: + schema: + type: object + properties: + user_id: + type: integer + + /admin/customer/name: + get: + tags: + - Users + summary: Get customer name + description: Get the full name of a customer + operationId: getCustomerName + parameters: + - name: user_id + in: query + schema: + type: integer + responses: + '200': + description: Customer name retrieved successfully + content: + application/json: + schema: + type: object + properties: + name: + type: string + + # Orders Endpoints + /orders: + get: + tags: + - Orders + summary: List orders + description: Retrieve a paginated list of orders + operationId: listOrders + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - name: show_wash_subscription + in: query + schema: + type: string + enum: [true, false] + responses: + '200': + description: Orders retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: + - Orders + summary: Create new order + description: Create a new wash order + operationId: createOrder + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderCreate' + responses: + '201': + description: Order created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + delete: + tags: + - Orders + summary: Delete order + description: Delete an existing order + operationId: deleteOrder + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order deleted successfully + content: + application/json: + schema: {} + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: + - Orders + summary: Update order (alias) + description: Update an existing order + operationId: updateOrders + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderUpdate' + responses: + '200': + description: Order updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /order: + get: + tags: + - Orders + summary: Get order details + description: Get detailed information about a specific order + operationId: getOrder + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '404': + $ref: '#/components/responses/NotFound' + put: + tags: + - Orders + summary: Update order + description: Update an existing order + operationId: updateOrder + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderUpdate' + responses: + '200': + description: Order updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /user/orders: + get: + tags: + - Orders + summary: Get current user's orders + description: Retrieve orders for the authenticated user + operationId: getUserOrders + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Orders retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + + /user/order: + get: + tags: + - Orders + summary: Get user's specific order + description: Get details of a specific order for the authenticated user + operationId: getUserOrder + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + + /orders/mark_as_completed: + post: + tags: + - Orders + summary: Mark order as completed + description: Mark an order as completed + operationId: markOrderCompleted + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + responses: + '200': + description: Order marked as completed successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /order/wash-certificate: + post: + tags: + - Orders + summary: Generate wash certificate + description: Generate a wash certificate for an order + operationId: generateWashCertificate + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + order_id: + type: integer + responses: + '200': + description: Wash certificate generated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + # Order Items Endpoints + /order/items: + get: + tags: + - Order Items + summary: List order items + description: Get all items for a specific order + operationId: listOrderItems + parameters: + - name: order_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order items retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OrderItem' + post: + tags: + - Order Items + summary: Add item to order + description: Add a new item to an existing order + operationId: addOrderItem + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderItemCreate' + responses: + '201': + description: Order item added successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + put: + tags: + - Order Items + summary: Update order item + description: Update an existing order item + operationId: updateOrderItem + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderItemUpdate' + responses: + '200': + description: Order item updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + delete: + tags: + - Order Items + summary: Delete order item + description: Remove an item from an order + operationId: deleteOrderItem + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order item deleted successfully + content: + application/json: + schema: {} + '404': + $ref: '#/components/responses/NotFound' + + # Departments Endpoints + /departments: + get: + tags: + - Departments + summary: List departments + description: Retrieve a list of all visible departments + operationId: listDepartments + parameters: + - name: id + in: query + schema: + type: integer + description: Filter by specific department ID + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Departments retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Department' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Departments + summary: Create department + description: Create a new department + operationId: createDepartment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentCreate' + responses: + '201': + description: Department created successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + put: + tags: + - Departments + summary: Update department + description: Update an existing department + operationId: updateDepartment + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentUpdate' + responses: + '200': + description: Department updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /departments/categories: + get: + tags: + - Departments + summary: Get department categories + description: Get product categories available in a department + operationId: getDepartmentCategories + parameters: + - name: department_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Department categories retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Departments + summary: Add category to department + description: Associate a product category with a department + operationId: addDepartmentCategory + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + department_id: + type: integer + category_id: + type: integer + responses: + '201': + description: Category added to department successfully + content: + application/json: + schema: {} + delete: + tags: + - Departments + summary: Remove category from department + operationId: removeDepartmentCategory + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/self-serve/enabled: + get: + tags: + - Departments + summary: Get department self-serve status + description: Check if self-serve is enabled for a specific department + operationId: getDepartmentSelfServeEnabled + parameters: + - name: id + in: query + required: true + description: Department ID + schema: + type: integer + responses: + '200': + description: Successfully retrieved status + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + '404': + $ref: '#/components/responses/NotFound' + put: + tags: + - Departments + summary: Update department self-serve status + description: Enable or disable self-serve for a specific department + operationId: updateDepartmentSelfServeEnabled + parameters: + - name: id + in: query + required: true + description: Department ID + schema: + type: integer + - name: enabled + in: query + required: true + description: Enabled status (true/false) + schema: + type: string + enum: ['true', 'false'] + responses: + '200': + description: Status updated successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + $ref: '#/components/responses/NotFound' + + /departments/order/recommended: + get: + tags: + - Departments + summary: Get recommended order for department + operationId: getDepartmentRecommendedOrder + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /department/lanes: + get: + tags: + - Departments + summary: List department lanes + description: Retrieve a list of all department lanes + operationId: listDepartmentLanes + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Department lanes retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentLane' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Departments + summary: Create department lane + description: Create a new department lane + operationId: createDepartmentLane + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentLaneCreate' + responses: + '201': + description: Department lane created successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + put: + tags: + - Departments + summary: Update department lane + description: Update an existing department lane + operationId: updateDepartmentLane + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentLaneUpdate' + responses: + '200': + description: Department lane updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/gates: + get: + tags: + - Departments + summary: List department gates + description: Retrieve department gates, optionally filtered by id + operationId: listDepartmentGates + parameters: + - name: id + in: query + required: false + schema: + type: integer + minimum: 1 + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Department gates retrieved successfully + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/DepartmentGate' + - type: array + items: + $ref: '#/components/schemas/DepartmentGate' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Departments + summary: Create department gate + operationId: createDepartmentGate + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGateCreate' + responses: + '201': + description: Department gate created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGate' + put: + tags: + - Departments + summary: Update department gate + operationId: updateDepartmentGate + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGateUpdate' + responses: + '200': + description: Department gate updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGate' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Departments + summary: Delete department gate + operationId: deleteDepartmentGate + parameters: + - name: id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Department gate deleted + content: + application/json: + schema: {} + + /department/relays: + get: + tags: + - Departments + summary: List department relays + description: Retrieve department relays, optionally filtered by id + operationId: listDepartmentRelays + parameters: + - name: id + in: query + required: false + schema: + type: integer + minimum: 1 + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Department relays retrieved successfully + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/DepartmentRelay' + - type: array + items: + $ref: '#/components/schemas/DepartmentRelay' + '401': + $ref: '#/components/responses/Unauthorized' + post: + tags: + - Departments + summary: Create department relay + operationId: createDepartmentRelay + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentRelayCreate' + responses: + '201': + description: Department relay created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentRelay' + put: + tags: + - Departments + summary: Update department relay + operationId: updateDepartmentRelay + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentRelayUpdate' + responses: + '200': + description: Department relay updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentRelay' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Departments + summary: Delete department relay + operationId: deleteDepartmentRelay + parameters: + - name: id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Department relay deleted + content: + application/json: + schema: {} + + /department/lanes/dynamic-image: + get: + tags: + - Departments + summary: Generate dynamic image for a department lane + description: | + Returns a composed machine UI image for the specified department lane. + You can optionally highlight button indices, set the current step indicator, and toggle only-current-step mode. + operationId: getDepartmentLaneDynamicImage + parameters: + - name: department + in: query + required: true + description: Department ID + schema: + type: integer + minimum: 1 + - name: lane + in: query + required: true + description: Lane ID + schema: + type: integer + minimum: 1 + - name: buttons + in: query + required: false + description: Highlighted button IDs (0-indexed). Accepts CSV, JSON array, or repeated query params. + schema: + oneOf: + - type: string + - type: array + items: + type: integer + - name: current_step + in: query + required: false + description: Current step indicator (non-negative integer) + schema: + type: integer + minimum: 0 + - name: only_current_step + in: query + required: false + description: If true, only draw the current step highlight + schema: + type: boolean + - name: vehicle_type + in: query + required: false + description: Vehicle type selection override (nullable non-negative integer) + schema: + type: integer + minimum: 0 + responses: + '200': + description: Dynamic image rendered successfully + content: + image/png: + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /guest/validation/customer-number: + post: + tags: + - Users + summary: Validate customer number + description: Check if a customer number is valid and exists + operationId: validateCustomerNumber + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - customer_number + properties: + customer_number: + type: integer + responses: + '200': + description: Customer number validation successful + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /guest/departments: + get: + tags: + - Departments + summary: List public departments + description: Get list of departments without authentication + operationId: listGuestDepartments + security: [] + parameters: + - name: include_lanes + in: query + description: Whether to include lane status and self-serve information + schema: + type: boolean + responses: + '200': + description: Departments retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentGuest' + + /department/selfserve/machine-types: + get: + tags: + - Self-Serve + summary: List reusable self-serve machine types + operationId: listSelfserveMachineTypes + parameters: + - name: id + in: query + required: true + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Successfully retrieved machine types + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/SelfserveMachineType' + - type: array + items: + $ref: '#/components/schemas/SelfserveMachineType' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Self-Serve + summary: Add reusable self-serve machine type + operationId: addSelfserveMachineType + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + nullable: true + responses: + '200': + description: Successfully added machine type + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveMachineType' + put: + tags: + - Self-Serve + summary: Update reusable self-serve machine type + operationId: updateSelfserveMachineType + parameters: + - name: id + in: query + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + nullable: true + responses: + '200': + description: Successfully updated machine type + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveMachineType' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: + - Self-Serve + summary: Delete reusable self-serve machine type + operationId: deleteSelfserveMachineType + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Successfully deleted machine type + content: + application/json: + schema: + type: string + example: Machine type deleted + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/questions: + get: + tags: + - Self-Serve + summary: List self-serve questions + description: Retrieve a list of self-serve questions for a department, lane, or product. + operationId: listSelfserveQuestions + parameters: + - name: id + in: query + description: Filter by question ID + schema: + type: integer + - name: department + in: query + description: Filter by department ID + schema: + type: integer + - name: lane + in: query + description: Filter by lane ID + schema: + type: integer + - name: product + in: query + description: Filter by product ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved questions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveQuestion' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add self-serve question + description: Add a new self-serve question. Questions are typically shared across departments and lanes by omitting department, lane, and product, which default to 0. + operationId: addSelfserveQuestion + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - question + - description + properties: + department: + type: integer + default: 0 + lane: + type: integer + default: 0 + product: + type: integer + default: 0 + question: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + default: 0 + responses: + '200': + description: Successfully added question + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveQuestion' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update self-serve question + description: Update an existing self-serve question. + operationId: updateSelfserveQuestion + parameters: + - name: id + in: query + required: true + description: Question ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + department: + type: integer + lane: + type: integer + product: + type: integer + question: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + responses: + '200': + description: Successfully updated question + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveQuestion' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + delete: + tags: + - Self-Serve + summary: Delete self-serve question + description: Delete a self-serve question by ID. + operationId: deleteSelfserveQuestion + parameters: + - name: id + in: query + required: true + description: Question ID + schema: + type: integer + responses: + '200': + description: Successfully deleted question + content: + application/json: + schema: + type: string + example: Question deleted + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/conditions: + get: + tags: + - Self-Serve + summary: List self-serve conditions + description: Retrieve a list of self-serve conditions for a department, lane, or product. + operationId: listSelfserveConditions + parameters: + - name: id + in: query + description: Filter by condition ID + schema: + type: integer + - name: department + in: query + description: Filter by department ID + schema: + type: integer + - name: lane + in: query + description: Filter by lane ID + schema: + type: integer + - name: product + in: query + description: Filter by product ID + schema: + type: integer + - name: condition_id + in: query + description: Filter by condition ID + schema: + type: integer + - name: machine_type_id + in: query + description: Filter by reusable machine type ID + schema: + type: integer + - name: machine_type_id + in: query + description: Filter by reusable machine type ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved conditions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveCondition' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add self-serve condition + description: Add a new self-serve condition. Either provide a reusable machine_type_id or a legacy department/lane/product scope. + operationId: addSelfserveCondition + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - description + properties: + department: + type: integer + default: 0 + lane: + type: integer + default: 0 + product: + type: integer + default: 0 + machine_type_id: + type: integer + nullable: true + condition_id: + type: integer + nullable: true + name: + type: string + description: + type: string + responses: + '200': + description: Successfully added condition + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveCondition' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update self-serve condition + description: Update an existing self-serve condition. + operationId: updateSelfserveCondition + parameters: + - name: id + in: query + required: true + description: Condition ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + department: + type: integer + lane: + type: integer + product: + type: integer + machine_type_id: + type: integer + nullable: true + condition_id: + type: integer + nullable: true + name: + type: string + description: + type: string + responses: + '200': + description: Successfully updated condition + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveCondition' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + tags: + - Self-Serve + summary: Delete self-serve condition + description: Delete a self-serve condition. + operationId: deleteSelfserveCondition + parameters: + - name: id + in: query + required: true + description: Condition ID + schema: + type: integer + responses: + '200': + description: Successfully deleted condition + content: + application/json: + schema: + type: string + example: Condition deleted + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/condition/rules: + get: + tags: + - Self-Serve + summary: List self-serve condition rules + description: Retrieve a list of self-serve condition rules. + operationId: listSelfserveConditionRules + parameters: + - name: id + in: query + description: Filter by rule ID + schema: + type: integer + - name: condition_id + in: query + description: Filter by condition ID + schema: + type: integer + - name: type + in: query + description: Filter by rule type + schema: + type: string + - name: object_type + in: query + description: Filter by object type + schema: + type: string + - name: object_id + in: query + description: Filter by object ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved condition rules + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveConditionRule' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add self-serve condition rule + description: Add a new self-serve condition rule. + operationId: addSelfserveConditionRule + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - condition_id + - type + - object_type + - object_id + - name + - description + properties: + condition_id: + type: integer + type: + type: string + object_type: + type: string + object_id: + type: integer + name: + type: string + description: + type: string + responses: + '200': + description: Successfully added condition rule + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveConditionRule' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update self-serve condition rule + description: Update an existing self-serve condition rule. + operationId: updateSelfserveConditionRule + parameters: + - name: id + in: query + required: true + description: Rule ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + condition_id: + type: integer + type: + type: string + object_type: + type: string + object_id: + type: integer + name: + type: string + description: + type: string + responses: + '200': + description: Successfully updated condition rule + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveConditionRule' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + tags: + - Self-Serve + summary: Delete self-serve condition rule + description: Delete a self-serve condition rule. + operationId: deleteSelfserveConditionRule + parameters: + - name: id + in: query + required: true + description: Rule ID + schema: + type: integer + responses: + '200': + description: Successfully deleted condition rule + content: + application/json: + schema: + type: string + example: Rule deleted + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/vehicle/conditions: + get: + tags: + - Self-Serve + summary: List vehicle conditions + description: Retrieve a list of vehicle conditions for a department, lane, reg, or question. Customers will only see their own vehicle conditions. + operationId: listSelfserveVehicleConditions + parameters: + - name: id + in: query + description: Filter by condition ID + schema: + type: integer + - name: department + in: query + description: Filter by department ID + schema: + type: integer + - name: lane + in: query + description: Filter by lane ID + schema: + type: integer + - name: reg + in: query + description: Filter by vehicle registration number + schema: + type: string + - name: question + in: query + description: Filter by question ID + schema: + type: integer + - name: customer_id + in: query + description: Filter by customer ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved vehicle conditions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add vehicle condition + description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. + operationId: addSelfserveVehicleCondition + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - department + - lane + - reg + - question + properties: + department: + type: integer + lane: + type: integer + reg: + type: string + question: + type: integer + value: + type: boolean + customer_id: + type: integer + nullable: true + responses: + '200': + description: Successfully added vehicle condition + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update vehicle condition + description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. + operationId: updateSelfserveVehicleCondition + parameters: + - name: id + in: query + required: true + description: Condition ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + department: + type: integer + lane: + type: integer + reg: + type: string + question: + type: integer + value: + type: boolean + customer_id: + type: integer + nullable: true + responses: + '200': + description: Successfully updated vehicle condition + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveVehicleConditionMutationResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + tags: + - Self-Serve + summary: Delete vehicle condition + description: Delete a vehicle condition. Customers can only delete conditions for their own vehicles. + operationId: deleteSelfserveVehicleCondition + parameters: + - name: id + in: query + required: true + description: Condition ID + schema: + type: integer + responses: + '200': + description: Successfully deleted vehicle condition + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Condition deleted + selfserve: + allOf: + - $ref: '#/components/schemas/SelfserveWashSummary' + nullable: true + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/vehicle/allowed: + get: + tags: + - Self-Serve + summary: Check whether self-serve is allowed for a vehicle on a lane + operationId: getSelfserveVehicleAllowed + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + - name: reg + in: query + required: true + schema: + type: string + - name: vehicle_type_id + in: query + required: false + description: Optional vehicle type override used when no vehicle is found by registration plate. + schema: + type: integer + minimum: 0 + - name: vehicle_type + in: query + required: false + description: Backward-compatible alias of `vehicle_type_id`. + schema: + type: integer + minimum: 0 + responses: + '200': + description: Successfully evaluated self-serve eligibility + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveVehicleAllowedResponse' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/washes/summary: + get: + tags: + - Self-Serve + summary: Get self-serve wash summary + operationId: getSelfserveWashSummary + parameters: + - name: session_id + in: query + required: false + schema: + type: integer + - name: lane_id + in: query + required: false + schema: + type: integer + - name: reg + in: query + required: false + schema: + type: string + - name: vehicle_type_id + in: query + required: false + description: Optional vehicle type override used to refresh summary data for unknown or reassigned plates. + schema: + type: integer + minimum: 0 + - name: vehicle_type + in: query + required: false + description: Backward-compatible alias of `vehicle_type_id`. + schema: + type: integer + minimum: 0 + responses: + '200': + description: Successfully retrieved self-serve wash summary + content: + application/json: + schema: + $ref: '#/components/schemas/SelfserveWashSummary' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/tasks: + get: + tags: + - Self-Serve + summary: List self-serve tasks + description: Retrieve a list of self-serve tasks for a department, lane, product, or condition_id. + operationId: listSelfserveTasks + parameters: + - name: id + in: query + description: Filter by task ID + schema: + type: integer + - name: department + in: query + description: Filter by department ID + schema: + type: integer + - name: lane + in: query + description: Filter by lane ID + schema: + type: integer + - name: product + in: query + description: Filter by product ID + schema: + type: integer + - name: condition_id + in: query + description: Filter by condition ID + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Successfully retrieved tasks + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentSelfserveTask' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + post: + tags: + - Self-Serve + summary: Add self-serve task + description: Add a new self-serve task. Either provide a reusable machine_type_id or a legacy department/lane/product scope. + operationId: addSelfserveTask + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - task + - description + properties: + department: + type: integer + default: 0 + lane: + type: integer + default: 0 + product: + type: integer + default: 0 + machine_type_id: + type: integer + nullable: true + condition_id: + type: integer + nullable: true + task: + type: string + description: + type: string + order_priority: + type: integer + default: 0 + services: + type: array + description: Optional services enabled by this task. Items must be valid service enum names. + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + description: Optional dynamic image button IDs enabled by this task. + items: + type: integer + default: [] + dynamic_images_vehicle_type: + type: integer + nullable: true + description: Optional vehicle type selection override for the machine UI. Integer >= 0 or null. + responses: + '200': + description: Successfully added task + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveTask' + '400': + $ref: '#/components/responses/BadRequest' + '500': + $ref: '#/components/responses/InternalServerError' + + put: + tags: + - Self-Serve + summary: Update self-serve task + description: Update an existing self-serve task. + operationId: updateSelfserveTask + parameters: + - name: id + in: query + required: true + description: Task ID + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: object + properties: + department: + type: integer + lane: + type: integer + product: + type: integer + machine_type_id: + type: integer + nullable: true + condition_id: + type: integer + nullable: true + task: + type: string + description: + type: string + order_priority: + type: integer + services: + type: array + nullable: true + description: Services enabled by this task. Set to null to clear all services. + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + nullable: true + description: Button IDs enabled by this task. Set to null to clear all buttons. + items: + type: integer + dynamic_images_vehicle_type: + type: integer + nullable: true + description: Vehicle type selection override. Set to null to clear. + responses: + '200': + description: Successfully updated task + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentSelfserveTask' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + delete: + tags: + - Self-Serve + summary: Delete self-serve task + description: Delete a self-serve task by ID. + operationId: deleteSelfserveTask + parameters: + - name: id + in: query + required: true + description: Task ID + schema: + type: integer + responses: + '200': + description: Successfully deleted task + content: + application/json: + schema: + type: string + example: Task deleted + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/tasks/attachments: + get: + tags: + - Self-Serve + summary: List task attachments + description: Retrieve a list of attachments for a specific self-serve task. + operationId: listSelfserveTaskAttachments + parameters: + - name: id + in: query + required: true + description: Task ID + schema: + type: integer + responses: + '200': + description: Successfully retrieved task attachments + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + delete: + tags: + - Self-Serve + summary: Delete task attachment + description: Remove an attachment from a specific self-serve task. + operationId: deleteSelfserveTaskAttachment + parameters: + - name: task_id + in: query + required: true + description: Task ID + schema: + type: integer + - name: attachment_id + in: query + required: true + description: Attachment ID + schema: + type: integer + responses: + '200': + description: Attachment deleted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Attachment deleted successfully + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + /department/selfserve/tasks/attachments/upload: + post: + tags: + - Self-Serve + summary: Upload task attachment + description: Upload a new attachment to a specific self-serve task using base64 encoding. + operationId: uploadSelfserveTaskAttachment + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - task_id + - base64_file + - file_name + properties: + task_id: + type: integer + base64_file: + type: string + description: Base64 encoded file content + file_name: + type: string + description: Name of the file including extension + responses: + '200': + description: Attachment uploaded successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /department/selfserve/tasks/attachments/download: + get: + tags: + - Self-Serve + summary: Download task attachment + description: Generate a download link for a specific self-serve task attachment. + operationId: downloadSelfserveTaskAttachment + parameters: + - name: task_id + in: query + required: true + description: Task ID + schema: + type: integer + - name: attachment_id + in: query + required: true + description: Attachment ID + schema: + type: integer + responses: + '200': + description: Successfully generated download link + content: + application/json: + schema: + type: object + properties: + download_link: + type: string + format: uri + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + + # Products Endpoints + /products: + get: + tags: + - Products + summary: List products + description: Retrieve a list of products with optional filters for customer pricing and department + operationId: listProducts + parameters: + - name: customer_id + in: query + schema: + type: integer + description: Customer ID for custom pricing + - name: department_id + in: query + schema: + type: integer + description: Department ID for department-specific pricing + - name: category + in: query + schema: + type: integer + description: Filter by category ID + - name: id + in: query + schema: + type: integer + description: Get specific product by ID + - name: final_price + in: query + schema: + type: boolean + description: Whether to return final prices including discounts + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Products retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Product' + post: + tags: + - Products + summary: Create product + description: Create a new product + operationId: createProduct + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductCreate' + responses: + '201': + description: Product created successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + put: + tags: + - Products + summary: Update product + description: Update an existing product + operationId: updateProduct + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductUpdate' + responses: + '200': + description: Product updated successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + # Categories Endpoints + /categories: + get: + tags: + - Categories + summary: List categories + description: Retrieve a list of product categories + operationId: listCategories + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Categories retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Category' + post: + tags: + - Categories + summary: Create category + description: Create a new product category + operationId: createCategory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CategoryCreate' + responses: + '201': + description: Category created successfully + content: + application/json: + schema: {} + put: + tags: + - Categories + summary: Update category + description: Update an existing category + operationId: updateCategory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CategoryUpdate' + responses: + '200': + description: Category updated successfully + content: + application/json: + schema: {} + + # Bookings Endpoints + /bookings: + get: + tags: + - Bookings + summary: List bookings + description: Retrieve a list of bookings + operationId: listBookings + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Bookings retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Booking' + put: + tags: + - Bookings + summary: Update booking + description: Update an existing booking + operationId: updateBooking + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BookingUpdate' + responses: + '200': + description: Booking updated successfully + content: + application/json: + schema: {} + + /user/bookings: + get: + tags: + - Bookings + summary: Get user bookings + description: Retrieve bookings for the authenticated user + operationId: getUserBookings + responses: + '200': + description: User bookings retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Booking' + + + /order-bookings: + get: + tags: + - Bookings + summary: List order bookings + operationId: listOrderBookings + parameters: + - name: id + in: query + required: false + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Goals Endpoints + /goals/department: + get: + tags: + - Goals + summary: List or get department goals + description: | + Retrieve a list of department goals or a single goal when `id` is provided. + + Access control: + - A user may only access goals where the goal's `departments` set is a subset of the user's departments. + - Users with the `superuser` permission may access all goals. + operationId: listDepartmentGoals + parameters: + - name: id + in: query + required: false + schema: { type: integer } + description: When provided, returns the single goal with this id (if accessible) + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Goals retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DepartmentGoal' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + post: + tags: + - Goals + summary: Create department goal + description: | + Create a new department goal. + + Access control: + - The provided `departments` must be a subset of the user's departments unless the user has `superuser`. + operationId: createDepartmentGoal + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGoalCreate' + responses: + '201': + description: Department goal created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGoal' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + put: + tags: + - Goals + summary: Update department goal + description: | + Update an existing department goal by `id`. + + Access control: + - The creator (`created_by`) may update regardless of department membership. + - Otherwise the user must satisfy the same subset rule as for read access, and any new `departments` provided must also be a subset unless the user has `superuser`. + operationId: updateDepartmentGoal + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGoalUpdate' + responses: + '200': + description: Department goal updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentGoal' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /goals/department/progress-alert/test: + post: + tags: + - Goals + summary: Send a test progress alert for a department goal + description: | + Sends a progress alert for a department goal to the destination defined in the goal's criteria. + + Permission required: `goals_department_progress_alert_test`. + + Access control: + - The caller must be a superuser or belong to all departments targeted by the goal. + + Behavior: + - Looks up the goal by `id`. + - Rebuilds the criteria from stored JSON and attaches the goal's departments. + - Renders the alert using the server-side renderer (respecting progress type/style/format and destination limits). + - Sends the alert to Slack, Email, or SMS depending on `progress_alert_destination`, unless overridden. + operationId: sendDepartmentGoalProgressAlertTest + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: integer + description: The department goal id + example: 42 + overrideDestination: + type: string + description: Override the destination for this test + enum: [SLACK, EMAIL, SMS, NONE] + example: SLACK + email_to: + type: string + format: email + description: Email recipient when destination is EMAIL + example: tester@example.com + subject: + type: string + description: Optional email subject when destination is EMAIL + example: Dept Goal Progress Test + sms_to: + description: One or more MSISDN recipients when destination is SMS + oneOf: + - type: string + description: Comma or semicolon separated list + example: "+4512345678, +4598765432" + - type: array + items: + type: string + example: ["+4512345678", "+4598765432"] + slack_webhook: + type: string + description: Slack webhook URL when destination is SLACK + example: https://hooks.slack.com/services/T000/B000/XXX + department_id: + type: integer + description: Department id to use that department's Slack webhook when destination is SLACK + example: 3 + responses: + '200': + description: Alert sent successfully + content: + application/json: + schema: + type: object + properties: + id: + type: integer + description: Goal id + destination: + type: string + description: Final destination used + enum: [SLACK, EMAIL, SMS, NONE] + target: + description: The target used for delivery (email address, phone numbers, department id, or webhook) + message_preview: + type: string + description: Rendered message preview + provider_response: + description: Provider-specific response or status message + '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' } + delete: + tags: + - Goals + summary: Delete department goal + description: | + Delete a department goal by `id`. + + Access control: + - The creator (`created_by`) may delete regardless of department membership. + - Otherwise the user must satisfy the subset rule or have `superuser`. + operationId: deleteDepartmentGoal + parameters: + - name: id + in: query + required: true + schema: { type: integer } + description: ID of the goal to delete + responses: + '200': + description: Department goal deleted successfully + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '/order-bookings': + post: + tags: + - Bookings + summary: Create order booking + operationId: createOrderBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department, reg_1, datetime, items] + properties: + customer_number: {type: integer} + department: {type: integer} + reg_1: {type: string} + reg_2: {type: string} + reg_3: {type: string} + datetime: {type: string, format: date-time} + note: {type: string} + reference: {type: string} + po: {type: string} + pickup: {type: boolean} + items: + type: array + items: + type: object + required: [id, quantity] + properties: + id: {type: integer} + quantity: {type: integer} + responses: + '200': + description: Success + put: + tags: + - Bookings + summary: Update order booking + operationId: updateOrderBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + customer_number: {type: integer} + department: {type: integer} + reg_1: {type: string} + reg_2: {type: string} + reg_3: {type: string} + datetime: {type: string, format: date-time} + note: {type: string} + reference: {type: string} + po: {type: string} + pickup: {type: boolean} + order_id: {type: integer} + items: + type: array + items: + type: object + required: [id, quantity] + properties: + id: {type: integer} + quantity: {type: integer} + responses: + '200': + description: Success + delete: + tags: + - Bookings + summary: Delete order booking + operationId: deleteOrderBooking + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + + /order-bookings/complete: + post: + tags: + - Bookings + summary: Complete order booking + operationId: completeOrderBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + safety_seal: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/bookings/sync: + post: + tags: + - Bookings + summary: Sync booking from external system + operationId: syncBooking + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/bookings/department/count: + get: + tags: + - Bookings + summary: Get department unfulfilled bookings count + operationId: getDepartmentBookingCount + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /user/bookings/washcertificate/download: + post: + tags: + - Bookings + summary: Get download link for own wash certificate + operationId: downloadOwnWashCertificate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /bookings/download_pdf: + get: + tags: + - Bookings + summary: Download booking PDF + operationId: downloadBookingPdf + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/bookings/delete: + post: + tags: + - Bookings + summary: Delete booking (admin) + operationId: adminDeleteBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/bookings/sync/all: + post: + tags: + - Bookings + summary: Sync all bookings from external system + operationId: syncAllBookings + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /admin/bookings/completeWashWithoutWashCertificate: + post: + tags: + - Bookings + summary: Complete wash without wash certificate + operationId: completeWashWithoutWashCertificate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /user/bookings/delete: + post: + tags: + - Bookings + summary: Delete own booking + operationId: deleteOwnBooking + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Invoices Endpoints + /invoices/draft: + get: + tags: + - Invoices + summary: List draft invoices + description: Retrieve a list of draft invoices + operationId: listDraftInvoices + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Draft invoices retrieved successfully + content: + application/json: + schema: {} + + /invoices/draft/close: + post: + tags: + - Invoices + summary: Close draft invoice + description: Close a draft invoice + operationId: closeDraftInvoice + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: integer + responses: + '200': + description: Draft invoice closed successfully + content: + application/json: + schema: {} + + /invoices/pdf: + get: + tags: + - Invoices + summary: Get invoice PDF + description: Download an invoice as PDF + operationId: getInvoicePdf + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: PDF retrieved successfully + content: + application/pdf: + schema: + type: string + format: binary + + /user/invoices: + get: + tags: + - Invoices + summary: Get user invoices + description: Retrieve invoices for the authenticated user + operationId: getUserInvoices + responses: + '200': + description: User invoices retrieved successfully + content: + application/json: + schema: {} + + /collected-invoices: + get: + tags: + - Invoices + summary: List collected invoices + description: Get list of collected invoices + operationId: listCollectedInvoices + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Collected invoices retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Invoices + summary: Create collected invoice + description: Create a new collected invoice + operationId: createCollectedInvoice + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '201': + description: Collected invoice created successfully + content: + application/json: + schema: {} + put: + tags: + - Invoices + summary: Update collected invoice + description: Update a collected invoice + operationId: updateCollectedInvoice + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Collected invoice updated successfully + content: + application/json: + schema: {} + + /collected-invoices/ready-to-invoice: + get: + tags: + - Invoices + summary: Get invoices ready to process + description: Get collected invoices that are ready to be processed + operationId: getReadyToInvoice + responses: + '200': + description: Ready invoices retrieved successfully + content: + application/json: + schema: {} + + /collected-invoices/economic: + post: + tags: + - Invoices + summary: Queue transfer of collected invoice to e-conomic + description: | + Queues collected invoice transfer to e-conomic. + Processing runs asynchronously and can be tracked through queue status endpoints. + operationId: queueCollectedInvoiceEconomicTransfer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: integer + minimum: 1 + send_as_is: + type: boolean + default: false + responses: + '202': + description: Collected invoice transfer queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '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/queue: + get: + tags: + - Invoices + summary: List collected-invoice e-conomic transfer queue jobs + operationId: listCollectedInvoiceEconomicQueueJobs + parameters: + - name: status + in: query + required: false + description: Comma-separated queue statuses to filter by. + schema: + type: string + example: "QUEUED,FAILED" + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + default: 50 + - name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Queue jobs retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueListResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/queue/status: + get: + tags: + - Invoices + summary: Get collected-invoice e-conomic transfer queue job status + operationId: getCollectedInvoiceEconomicQueueJobStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '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/queue/retry: + post: + tags: + - Invoices + summary: Retry failed collected-invoice queue job + operationId: retryCollectedInvoiceEconomicQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '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/compare: + get: + tags: + - Invoices + summary: Compare collected invoice totals with E-conomic + description: | + Compares a collected invoice in the system with its corresponding invoice in E-conomic. + Returns totals from both sources, their difference, and any warnings detected during comparison. + operationId: compareCollectedInvoiceEconomic + parameters: + - name: collected_invoice_id + in: query + required: true + description: The internal collected invoice ID to compare + schema: + type: integer + minimum: 1 + responses: + '200': + description: Comparison completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicCompareResponse' + '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: + get: + tags: + - Invoices + summary: Get deep V2 e-conomic invoice details + description: | + Returns normalized internal lines and best-effort fetched draft/booked e-conomic lines + for a collected invoice, including department distributions and warnings. + operationId: getCollectedInvoiceEconomicV2Details + parameters: + - name: collected_invoice_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Details resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse' + '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/compare: + get: + tags: + - Invoices + summary: Compare internal invoice with draft/booked (V2) + operationId: compareCollectedInvoiceEconomicV2 + parameters: + - name: collected_invoice_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Comparison completed + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareResponse' + examples: + exactMatch: + summary: Exact match between internal and draft/booked + value: + collected_invoice_id: 123 + warnings: [] + comparison: + totals: + internal_net_total: 694 + targets: + draft: + target: draft + status: exact_match + overall_match: true + booked: + target: booked + status: exact_match + overall_match: true + partialMismatch: + summary: Partial mismatch with line and department differences + value: + collected_invoice_id: 123 + warnings: + - Non-billable line count differs + comparison: + totals: + internal_net_total: 694 + targets: + draft: + target: draft + status: partial_mismatch + overall_match: false + mismatch_reasons: + - quantity_mismatch + - department_total_mismatch + missingBooked: + summary: Missing booked target + value: + collected_invoice_id: 123 + comparison: + totals: + internal_net_total: 694 + targets: + booked: + target: booked + status: missing_target + overall_match: false + '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/compare/bulk: + post: + tags: + - Invoices + summary: Bulk compare collected invoices against draft/booked (V2) + operationId: compareCollectedInvoiceEconomicV2Bulk + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [collected_invoice_ids] + properties: + collected_invoice_ids: + type: array + minItems: 1 + maxItems: 200 + items: + type: integer + minimum: 1 + responses: + '200': + description: Bulk comparison completed + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareBulkResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /collected-invoices/economic/v2/revenue-statistics: + get: + tags: + - Invoices + summary: Get overall booked revenue statistics from e-conomic (V2) + description: | + Aggregates booked e-conomic revenue across invoices and lines, with optional filters + for date range, customer(s), department(s), currency, and barred-customer status. + operationId: getCollectedInvoiceEconomicV2RevenueStatistics + parameters: + - name: dateFrom + in: query + required: false + description: Start date (inclusive), defaults to first day of current month. + schema: + type: string + format: date + - name: dateTo + in: query + required: false + description: End date (inclusive), defaults to today. + schema: + type: string + format: date + - name: customer_numbers + in: query + required: false + description: Comma-separated customer numbers to include. + schema: + type: string + example: "42493959,42493960" + - name: department_numbers + in: query + required: false + description: Comma-separated department numbers to include. + schema: + type: string + example: "75,10" + - name: currency + in: query + required: false + description: Restrict to a specific invoice currency. + schema: + type: string + example: "DKK" + - name: barred + in: query + required: false + description: Filter by e-conomic customer barred status. + schema: + type: string + enum: [all, barred, active] + default: all + - name: max_pages + in: query + required: false + description: Safety cap for paginated e-conomic reads. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 10 + responses: + '200': + description: Revenue statistics resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2RevenueStatisticsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period: + get: + tags: + - Invoices + summary: Get invoicing periods + description: Retrieve invoicing periods for superusers + operationId: getInvoicingPeriods + parameters: + - name: dateFrom + in: query + required: true + schema: {type: string, format: date} + - name: dateTo + in: query + required: true + schema: {type: string, format: date} + responses: + '200': + description: Invoicing periods retrieved successfully + content: + application/json: + schema: {} + + /superuser/invoicing/period/distribution/fixed-pricing: + get: + tags: + - Invoices + summary: Get fixed pricing distribution + description: Get invoicing distribution for fixed pricing items + operationId: getInvoicingFixedPricingDistribution + parameters: + - name: dateFrom + in: query + required: true + schema: {type: string, format: date} + - name: dateTo + in: query + required: true + schema: {type: string, format: date} + responses: + '200': + description: Fixed pricing distribution retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionResponse' + + /superuser/invoicing/period/distribution/wash-subscriptions: + get: + tags: + - Invoices + summary: Get wash subscriptions distribution + description: Get invoicing distribution for wash subscriptions + operationId: getInvoicingWashSubscriptionsDistribution + parameters: + - name: dateFrom + in: query + required: true + schema: {type: string, format: date} + - name: dateTo + in: query + required: true + schema: {type: string, format: date} + responses: + '200': + description: Wash subscriptions distribution retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingWashSubscriptionsDistributionResponse' + + /superuser/invoicing/period/distribution/v2/all: + get: + tags: + - Invoices + summary: Get version-aware historical distribution (all) + operationId: getInvoicingPeriodDistributionV2All + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware historical distribution (all categories) + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2AllResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/fixed-pricing: + get: + tags: + - Invoices + summary: Get version-aware historical fixed pricing distribution + operationId: getInvoicingPeriodDistributionV2FixedPricing + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware fixed pricing distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2FixedPricingResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/wash-subscriptions: + get: + tags: + - Invoices + summary: Get version-aware historical wash subscription distribution + operationId: getInvoicingPeriodDistributionV2WashSubscriptions + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware wash subscription distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/customer-prices: + get: + tags: + - Invoices + summary: Get version-aware historical customer-price discount distribution + operationId: getInvoicingPeriodDistributionV2CustomerPrices + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Version-aware customer-price discount distribution + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/invoicing/period/distribution/v2/booked-department-75: + get: + tags: + - Invoices + summary: Get booked e-conomic department 75 redistribution + operationId: getInvoicingPeriodDistributionV2BookedDepartment75 + parameters: + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Actual booked e-conomic department 75 net amounts redistributed to internal departments + content: + application/json: + schema: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/customers/pricing-history: + get: + tags: + - Invoices + summary: Get customer versioned pricing/subscription/discount timeline + operationId: getCustomerPricingHistoryV2 + parameters: + - name: customer_number + in: query + required: true + schema: + type: integer + minimum: 1 + - name: dateFrom + in: query + required: true + schema: { type: string, format: date } + - name: dateTo + in: query + required: true + schema: { type: string, format: date } + responses: + '200': + description: Customer timeline resolved + content: + application/json: + schema: + $ref: '#/components/schemas/CustomerPricingHistoryResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '500': { $ref: '#/components/responses/InternalServerError' } + + # Vehicles Endpoints + /vehicles: + get: + tags: + - Vehicles + summary: List vehicles + description: | + List vehicles or fetch a specific vehicle when `id` is provided. + + - When `id` is present, returns a single vehicle object (404 if not found). + - Otherwise returns a paginated list of vehicles. + + Permissions: + - Own scope: `list_own_vehicles` (linked to subuser node `VEHICLES_LIST`). + - Broader scope: `list_vehicles_other`. + + Subusers may specify header `X-Customer-Number` to target a specific customer. If the broader + permission is missing, the list will automatically be restricted to the effective customer context. + operationId: listVehicles + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/XCustomerNumber' + - name: id + in: query + schema: {type: integer} + - name: reg + in: query + schema: {type: string} + - name: customer_id + in: query + schema: {type: integer} + responses: + '200': + description: Vehicle(s) retrieved successfully + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Vehicle' + - type: array + items: + $ref: '#/components/schemas/Vehicle' + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + post: + tags: + - Vehicles + summary: Add vehicle + operationId: addVehicle + description: | + Create a new vehicle for a customer. + + Permissions: + - Own scope: `add_vehicle` (linked to subuser node `VEHICLES_ADD`). + - Broader scope: `add_vehicle_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [reg, type, wash_subscription] + properties: + reg: + type: string + minLength: 2 + maxLength: 12 + description: Vehicle registration number + type: + type: integer + description: Product ID representing the vehicle wash type + wash_subscription: + type: boolean + reference: + type: string + maxLength: 255 + nullable: true + customer_id: + type: integer + description: Optional explicit target customer. Defaults to the effective customer context. + responses: + '200': + description: Vehicle created + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + put: + tags: + - Vehicles + summary: Edit vehicle + operationId: editVehicle + description: | + Update fields on an existing vehicle. + + Permissions: + - Own scope: `edit_vehicle` (linked to subuser node `VEHICLES_EDIT`). + - Broader scope: `edit_vehicle_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + reg: + type: string + minLength: 2 + maxLength: 12 + type: {type: integer} + wash_subscription: {type: boolean} + reference: + type: string + maxLength: 255 + nullable: true + responses: + '200': + description: Vehicle updated + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: + - Vehicles + summary: Delete vehicle + operationId: deleteVehicle + description: | + Delete an existing vehicle. + + Permissions: + - Own scope: `delete_vehicle` (linked to subuser node `VEHICLES_DELETE`). + - Broader scope: `delete_vehicle_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Vehicle deleted + content: + application/json: + schema: {} + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /vehicles/addons/available: + get: + tags: + - Vehicles + summary: Get available vehicle addons + description: | + Get list of available addons for a vehicle. + + Permissions: + - Own scope: `list_vehicle_addon_own` (linked to subuser node `VEHICLES_LIST`). + - Broader scope: `list_vehicles_addon_other`. + operationId: getAvailableVehicleAddons + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Available addons retrieved successfully + content: + application/json: + schema: {} + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /vehicles/addons/toggle: + post: + tags: + - Vehicles + summary: Toggle vehicle addon + description: | + Enable or disable a vehicle addon for a vehicle. + + Permissions: + - Own scope: `toggle_vehicle_addon_own` (linked to subuser node `VEHICLES_EDIT`). + - Broader scope: `toggle_vehicle_addon_other`. + operationId: toggleVehicleAddon + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [vehicle_id, addon_id] + properties: + vehicle_id: + type: integer + addon_id: + type: integer + responses: + '200': + description: Vehicle addon toggled successfully + content: + application/json: + schema: {} + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /department/vehicles/unknown-customer: + get: + tags: + - Vehicles + summary: Get unknown customer vehicles in department + operationId: getUnknownCustomerVehicles + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /department/vehicle/customer-suggestions: + get: + tags: + - Vehicles + summary: Get vehicle customer suggestions + operationId: getVehicleCustomerSuggestions + parameters: + - name: reg + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /vehicles/set-auto-start-on-lpr: + post: + tags: + - Vehicles + summary: Set auto start on LPR + operationId: setVehicleAutoStartOnLpr + description: | + Enable or disable automatic start on LPR for a vehicle in XL Vask. + + Permissions: + - Own scope: `set_auto_start_on_lpr` (linked to subuser node `VEHICLES_EDIT`). + - Broader scope: `set_auto_start_on_lpr_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, active] + properties: + id: {type: integer} + active: {type: boolean} + responses: + '200': + description: Success + content: + application/json: + schema: {} + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /vehicles/set-vehicle-type-id: + post: + tags: + - Vehicles + summary: Set vehicle type ID + operationId: setVehicleTypeId + description: | + Set or change the XL Vask `vehicleTypeId` for a vehicle. + + Permissions: + - Own scope: `set_vehicle_type_id` (linked to subuser node `VEHICLES_EDIT`). + - Broader scope: `set_vehicle_type_id_other`. + parameters: + - $ref: '#/components/parameters/XCustomerNumber' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, vehicleTypeId] + properties: + id: {type: integer} + vehicleTypeId: + type: string + minLength: 1 + maxLength: 50 + responses: + '200': + description: Success + content: + application/json: + schema: {} + '400': { $ref: '#/components/responses/BadRequest' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /superuser/users-with-vehicle-subscriptions: + get: + tags: + - Vehicles + summary: Get users with vehicle subscriptions + operationId: getUsersWithVehicleSubscriptions + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /vehicles/status: + get: + tags: + - Vehicles + summary: Get vehicle status + operationId: getVehicleStatus + parameters: + - name: reg + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /vehicles/search: + get: + tags: + - Vehicles + summary: Search vehicles + operationId: searchVehicles + parameters: + - name: search + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Notifications Endpoints + /notifications: + get: + tags: + - Notifications + summary: List notifications + description: Get list of notifications + operationId: listNotifications + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Notifications retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Notification' + post: + tags: + - Notifications + summary: Create notification + description: Create a new notification + operationId: createNotification + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationCreate' + responses: + '201': + description: Notification created successfully + content: + application/json: + schema: {} + delete: + tags: + - Notifications + summary: Delete notification + description: Delete a notification + operationId: deleteNotification + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Notification deleted successfully + content: + application/json: + schema: {} + + # Statistics Endpoints + /statistics/bookings/new: + get: + tags: + - Statistics + summary: Get new bookings statistics + description: Get statistics for new bookings + operationId: getNewBookingsStats + responses: + '200': + description: New bookings statistics retrieved successfully + content: + application/json: + schema: {} + + /orders/module/stripe/payment_intent: + get: + tags: + - Orders + summary: Get Stripe payment intent + operationId: getStripePaymentIntent + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Orders + summary: Create Stripe payment intent + operationId: createStripePaymentIntent + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, reader] + properties: + id: {type: integer} + reader: {type: string} + tax_percentage: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + delete: + tags: + - Orders + summary: Delete Stripe payment intent + operationId: deleteStripePaymentIntent + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /orders/module/stripe/payment_intent/capture: + post: + tags: + - Orders + summary: Capture Stripe payment intent + operationId: captureStripePaymentIntent + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /orders/module/stripe/debug/simulate_payment: + post: + tags: + - Orders + summary: Simulate Stripe payment + operationId: simulateStripePayment + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /statistics/orders/new: + get: + tags: + - Statistics + summary: Get new orders statistics + description: Get statistics for new orders + operationId: getNewOrdersStats + responses: + '200': + description: New orders statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/today: + get: + tags: + - Statistics + summary: Get today's income + description: Get income statistics for today + operationId: getTodayIncome + responses: + '200': + description: Today's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/yesterday: + get: + tags: + - Statistics + summary: Get yesterday's income + description: Get income statistics for yesterday + operationId: getYesterdayIncome + responses: + '200': + description: Yesterday's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/this-month: + get: + tags: + - Statistics + summary: Get this month's income + description: Get income statistics for the current month + operationId: getThisMonthIncome + responses: + '200': + description: This month's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/last-month: + get: + tags: + - Statistics + summary: Get last month's income + description: Get income statistics for the previous month + operationId: getLastMonthIncome + responses: + '200': + description: Last month's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/this-year: + get: + tags: + - Statistics + summary: Get this year's income + description: Get income statistics for the current year + operationId: getThisYearIncome + responses: + '200': + description: This year's income statistics retrieved successfully + content: + application/json: + schema: {} + + /statistics/income/departments: + get: + tags: + - Statistics + summary: Get total income today by departments + operationId: getTotalIncomeTodayByDepartments + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /statistics/economic/totals: + get: + tags: + - Statistics + summary: Get total economic statistics + operationId: getEconomicTotals + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /statistics/economic/totals/department_sent_invoice_totals: + get: + tags: + - Statistics + summary: Get department sent invoice totals + operationId: getDepartmentSentInvoiceTotals + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /statistics/economic/totals/department_draft_invoice_totals: + get: + tags: + - Statistics + summary: Get department draft invoice totals + operationId: getDepartmentDraftInvoiceTotals + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Worker Endpoints + /worker/version: + get: + tags: + - Worker + summary: Get worker version + description: Get the current version of the system worker + operationId: getWorkerVersion + responses: + '200': + description: Worker version retrieved successfully + content: + application/json: + schema: {} + + /worker/update-version: + get: + tags: + - Worker + summary: Update worker version + description: Set the target version for the worker update + operationId: updateWorkerVersion + parameters: + - name: version + in: query + required: true + schema: + type: string + responses: + '200': + description: Version update target set successfully + content: + application/json: + schema: {} + + /worker/status: + get: + tags: + - Worker + summary: Get worker status + description: Get detailed status of the system worker + operationId: getWorkerStatus + responses: + '200': + description: Worker status retrieved successfully + content: + application/json: + schema: {} + + /worker/debug: + get: + tags: + - Worker + summary: Debug worker + description: Execute debug commands on the worker (often restricted) + operationId: debugWorker + responses: + '200': + description: Debug information retrieved successfully + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + + /worker/debug/on: + get: + tags: + - Worker + summary: Enable worker debug + operationId: enableWorkerDebug + responses: + '200': + description: Worker debug enabled + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + + /worker/debug/off: + get: + tags: + - Worker + summary: Disable worker debug + operationId: disableWorkerDebug + responses: + '200': + description: Worker debug disabled + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + + /worker/licenseplates: + get: + tags: + - Worker + summary: Get unique license plates + description: Fetch all unique license plates from various database tables + operationId: getWorkerLicensePlates + responses: + '200': + description: License plates retrieved successfully + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + + /economic/doesCustomerExist: + get: + tags: + - Modules + summary: Check if customer exists in e-conomic + operationId: checkEconomicCustomerExists + parameters: + - name: cvr + in: query + required: true + schema: + type: string + responses: + '200': + description: Customer check completed + content: + application/json: + schema: {} + '404': + $ref: '#/components/responses/NotFound' + + /cvr/lookup: + get: + tags: + - Modules + summary: Lookup CVR information + description: Get detailed information for a CVR number + operationId: lookupCvr + parameters: + - name: cvr + in: query + required: true + schema: + type: string + responses: + '200': + description: CVR information retrieved successfully + content: + application/json: + schema: {} + + /cvr/search: + get: + tags: + - Modules + summary: Search CVR + description: Search for companies by name or CVR + operationId: searchCvr + parameters: + - name: query + in: query + required: true + schema: + type: string + minLength: 2 + responses: + '200': + description: Search results retrieved successfully + content: + application/json: + schema: {} + + # Plate Scans Endpoints + /numberplatescans: + get: + tags: + - Plate Scans + summary: List plate scans + description: Get a list of license plate scans + operationId: listPlateScans + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Plate scans retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Plate Scans + summary: Record plate scan + description: Record a new license plate scan + operationId: recordPlateScan + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - plate + - lane_id + properties: + plate: + type: string + lane_id: + type: integer + responses: + '201': + description: Plate scan recorded successfully + content: + application/json: + schema: {} + + /numberplatescans/department: + post: + tags: + - Plate Scans + summary: Record plate scan for department + operationId: recordDepartmentPlateScan + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - plate + - department_id + properties: + plate: + type: string + department_id: + type: integer + responses: + '201': + description: Plate scan recorded successfully + content: + application/json: + schema: {} + + /numberplatescans/post: + get: + tags: + - Plate Scans + summary: Get post-scan results + operationId: getPlateScanPostResults + responses: + '200': + description: Post-scan results retrieved successfully + content: + application/json: + schema: {} + + /numberplatescanners: + get: + tags: + - Plate Scans + summary: List plate scanners + description: Get a list of all number plate scanners + operationId: listPlateScanners + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + - $ref: '#/components/parameters/SearchParam' + responses: + '200': + description: Plate scanners retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Plate Scans + summary: Add plate scanner + operationId: addPlateScanner + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, name, notes] + properties: + department_id: {type: integer} + name: {type: string} + notes: {type: string} + responses: + '201': + description: Plate scanner added successfully + content: + application/json: + schema: {} + put: + tags: + - Plate Scans + summary: Update plate scanner + operationId: updatePlateScanner + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, department_id, name, notes] + properties: + id: {type: integer} + department_id: {type: integer} + name: {type: string} + notes: {type: string} + responses: + '200': + description: Plate scanner updated successfully + content: + application/json: + schema: {} + + /department/numberplatescanners: + get: + tags: + - Plate Scans + summary: List department plate scanners + operationId: listDepartmentPlateScanners + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Department plate scanners retrieved successfully + content: + application/json: + schema: {} + + /relay/button/press/post: + get: + tags: + - Plate Scans + summary: Record machine start button press webhook + operationId: addButtonPress + parameters: + - name: token + in: query + required: false + schema: {type: string} + - name: lane_id + in: query + required: false + schema: + type: integer + - name: reg + in: query + required: false + schema: + type: string + responses: + '201': + description: Button press recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '404': + $ref: '#/components/responses/NotFound' + post: + tags: + - Plate Scans + summary: Record machine start button press webhook + operationId: addButtonPressPost + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + token: + type: string + lane_id: + type: integer + reg: + type: string + responses: + '201': + description: Button press recorded and linked to a self-serve wash session + content: + application/json: + schema: + $ref: '#/components/schemas/MachineButtonPressWebhookResponse' + '404': + $ref: '#/components/responses/NotFound' + + # Module - e-conomic Endpoints + /economic/customers/import: + post: + tags: + - Modules + summary: Import e-conomic customers + description: Import customers from e-conomic + operationId: importEconomicCustomers + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Customers imported successfully + content: + application/json: + schema: {} + + /economic/departments: + get: + tags: + - Modules + summary: Get e-conomic departments + operationId: getEconomicDepartments + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /economic/products: + get: + tags: + - Modules + summary: Get e-conomic products + operationId: getEconomicProducts + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/economic/customer: + get: + tags: + - Modules + summary: Get e-conomic customer details + operationId: getEconomicCustomer + parameters: + - name: customer_number + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Modules + summary: Create e-conomic customer + operationId: createEconomicCustomer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customer_number, cvr, email, phone, name] + properties: + customer_number: {type: integer} + cvr: {type: integer} + email: {type: string} + phone: {type: integer} + name: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /economic/layouts: + get: + tags: + - Modules + summary: Get e-conomic layouts + description: Get available invoice layouts from e-conomic + operationId: getEconomicLayouts + responses: + '200': + description: Layouts retrieved successfully + content: + application/json: + schema: {} + + /economic/payment-terms: + get: + tags: + - Modules + summary: Get e-conomic payment terms + description: Get available payment terms from e-conomic + operationId: getEconomicPaymentTerms + responses: + '200': + description: Payment terms retrieved successfully + content: + application/json: + schema: {} + + /economic/invoice/draft/export: + post: + tags: + - Modules + summary: Queue draft invoice export to e-conomic + description: Queue a draft invoice export job for asynchronous processing. + operationId: queueDraftInvoiceExportToEconomic + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [order_id] + properties: + order_id: + type: integer + minimum: 1 + responses: + '202': + description: Draft invoice export queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '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' } + + /economic/invoice/draft/export/status: + get: + tags: + - Modules + summary: Get queued draft export job status + operationId: getDraftInvoiceExportQueueStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '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' } + + /economic/invoice/draft/export/retry: + post: + tags: + - Modules + summary: Retry failed draft export queue job + operationId: retryDraftInvoiceExportQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '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' } + + /economic/invoice/export: + post: + tags: + - Modules + summary: Queue invoice export to e-conomic + description: Queue booked invoice export job for asynchronous processing. + operationId: queueInvoiceExportToEconomic + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [order_id] + properties: + order_id: + type: integer + minimum: 1 + responses: + '202': + description: Invoice export queued + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueEnqueueResponse' + '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' } + + /economic/invoice/export/status: + get: + tags: + - Modules + summary: Get queued invoice export job status + operationId: getInvoiceExportQueueStatus + parameters: + - name: job_id + in: query + required: true + schema: + type: integer + minimum: 1 + responses: + '200': + description: Queue job status + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueStatusResponse' + '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' } + + /economic/invoice/export/retry: + post: + tags: + - Modules + summary: Retry failed invoice export queue job + operationId: retryInvoiceExportQueueJob + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [job_id] + properties: + job_id: + type: integer + minimum: 1 + responses: + '200': + description: Queue job retried + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicTransferQueueRetryResponse' + '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' } + + # Module - Stripe Endpoints + /modules/stripe/customers: + get: + tags: + - Modules + summary: List Stripe customers + description: Get list of Stripe customers + operationId: listStripeCustomers + responses: + '200': + description: Stripe customers retrieved successfully + content: + application/json: + schema: {} + + /modules/stripe/products: + get: + tags: + - Modules + summary: List Stripe products + description: Get list of Stripe products + operationId: listStripeProducts + responses: + '200': + description: Stripe products retrieved successfully + content: + application/json: + schema: {} + + /modules/stripe/prices: + get: + tags: + - Modules + summary: List Stripe prices + description: Get list of Stripe prices + operationId: listStripePrices + responses: + '200': + description: Stripe prices retrieved successfully + content: + application/json: + schema: {} + + /modules/stripe/invoice: + post: + tags: + - Modules + summary: Create Stripe invoice + description: Create an invoice in Stripe + operationId: createStripeInvoice + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '201': + description: Stripe invoice created successfully + content: + application/json: + schema: {} + + /modules/stripe/terminal/readers: + get: + tags: + - Modules + summary: List Stripe terminal readers + operationId: listStripeTerminalReaders + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/stripe/terminal/locations: + get: + tags: + - Modules + summary: List Stripe terminal locations + operationId: listStripeTerminalLocations + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/stripe/department/terminal/location: + get: + tags: + - Modules + summary: Get department terminal location + operationId: getDepartmentTerminalLocation + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Modules + summary: Set department terminal location + operationId: setDepartmentTerminalLocation + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, location] + properties: + id: {type: integer} + location: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/stripe/department/terminal/readers: + get: + tags: + - Modules + summary: Get department terminal readers + operationId: getDepartmentTerminalReaders + parameters: + - name: id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Module - Backup Endpoints + /modules/backup/backups: + get: + tags: + - Modules + summary: List backup modules + operationId: listBackupModules + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Modules + summary: Create backup module + operationId: createBackupModule + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, description] + properties: + name: {type: string} + description: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Module - XLVask Endpoints + /modules/xlvask/usageLog: + get: + tags: + - Modules + summary: Get XLVask usage logs + description: Retrieve usage logs from XLVask system + operationId: getXlvaskUsageLogs + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Usage logs retrieved successfully + content: + application/json: + schema: {} + + /modules/xlvask/vehicles: + get: + tags: + - Modules + summary: List XLVask vehicles + description: Get list of vehicles from XLVask + operationId: listXlvaskVehicles + responses: + '200': + description: XLVask vehicles retrieved successfully + content: + application/json: + schema: {} + + /modules/xlvask/customers: + get: + tags: + - Modules + summary: List XLVask customers + description: Get list of customers from XLVask + operationId: listXlvaskCustomers + responses: + '200': + description: XLVask customers retrieved successfully + content: + application/json: + schema: {} + + /modules/action-logs: + get: + tags: + - Modules + summary: List module action logs + description: Retrieve a paginated list of module action logs with searching and filtering + operationId: listModuleActionLogs + parameters: + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/SearchParam' + - $ref: '#/components/parameters/FiltersParam' + responses: + '200': + description: Module action logs retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ModuleActionLog' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # Module - Self-Serve Endpoints + /modules/self-serve/lane/status: + get: + tags: + - Modules + summary: Get self-serve lane status + description: Retrieve the current status of a self-serve lane + operationId: getSelfServeLaneStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Lane status retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneStatus' + + /modules/self-serve/lane/wash/in-progress: + get: + tags: + - Modules + summary: Get in-progress self-serve wash customer and vehicle details + description: | + Returns the current open self-serve wash session details for a lane (if any), + including resolved customer and vehicle details. + operationId: getSelfServeLaneWashInProgress + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: In-progress wash details resolved + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + in_progress: + type: boolean + session: + type: object + nullable: true + properties: + id: + type: integer + status: + type: string + reg: + type: string + customer_number: + type: integer + nullable: true + vehicle_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + included_minutes: + type: integer + nullable: true + machine_type_id: + type: integer + nullable: true + machine_relay_enabled: + type: boolean + machine_relay_enabled_at: + type: string + nullable: true + machine_start_triggered: + type: boolean + machine_start_triggered_at: + type: string + nullable: true + wash_started_at: + type: string + nullable: true + created_at: + type: string + updated_at: + type: string + nullable: true + customer: + type: object + nullable: true + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + nullable: true + display_name: + type: string + nullable: true + email: + type: string + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: string + nullable: true + vehicle: + type: object + nullable: true + properties: + id: + type: integer + customer_id: + type: integer + type: + type: integer + reg: + type: string + reference: + type: string + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/command: + post: + tags: + - Modules + summary: Send self-serve lane command + description: | + Send a command (e.g., start, stop, reset) to a self-serve lane. + Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here. + operationId: sendSelfServeLaneCommand + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - command + properties: + lane_id: + type: integer + command: + type: string + enum: [START, STOP, RESET, RESERVE, RELEASE, OPEN_PROPERTY_ACCESS_GATE, OPEN_PROPERTY_EXIT_GATE] + license_plate: + type: string + description: Required for START command + responses: + '200': + description: Command sent successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneStatus' + '400': + description: Command execution failed + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Failed to execute command: Failed to open property access gate.' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/services/allowed: + post: + tags: + - Modules + summary: Set allowed services for a lane based on shown tasks + description: | + Updates the set of services that are allowed to be manually activated for a given self-serve lane, + derived from the tasks currently shown to the user after answering the self-serve questions. + This endpoint does not activate anything by itself; it only sets what is allowed to be activated. + operationId: setSelfServeLaneAllowedServices + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + properties: + lane_id: + type: integer + task_ids: + type: array + description: List of task IDs that are currently shown to the user + items: + type: integer + responses: + '200': + description: Allowed services updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + allowed_services: + type: array + items: + type: string + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/gate/open: + post: + tags: + - Modules + summary: Open a self-serve lane gate + description: | + Opens either the ENTRANCE or EXIT gate relay for a self-serve lane. + Failures return a sanitized gate-specific message. + operationId: openSelfServeLaneGate + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - gate + properties: + lane_id: + type: integer + gate: + type: string + enum: [ENTRANCE, EXIT] + responses: + '200': + description: Lane gate opened + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + gate: + type: string + enum: [ENTRANCE, EXIT] + opened: + type: boolean + state: + type: string + '400': + description: Gate open failed + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Failed to open entrance gate. + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine/status: + get: + tags: + - Modules + summary: Get MACHINE relay status for a lane + description: | + Reads the current Shelly MACHINE relay status (`on`/`off`) for the given lane. + operationId: getSelfServeLaneMachineRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: MACHINE relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_program_picker/status: + get: + tags: + - Modules + summary: Get MACHINE_PROGRAM_PICKER relay status for a lane + description: | + Reads the current Shelly MACHINE_PROGRAM_PICKER relay status (`on`/`off`) for the given lane. + operationId: getSelfServeLaneMachineProgramPickerRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: MACHINE_PROGRAM_PICKER relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_program_picker/set: + post: + tags: + - Modules + summary: Set MACHINE_PROGRAM_PICKER relay status for a lane + description: | + Sets the Shelly MACHINE_PROGRAM_PICKER relay state for the lane to on or off and returns the latest status. + operationId: setSelfServeLaneMachineProgramPickerRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + responses: + '200': + description: MACHINE_PROGRAM_PICKER relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE_PROGRAM_PICKER] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_cleaner/status: + get: + tags: + - Modules + summary: Get MACHINE_CLEANER relay status for a lane + description: | + Reads the current Shelly MACHINE_CLEANER relay status (`on`/`off`) for the given lane. + operationId: getSelfServeLaneMachineCleanerRelayStatus + parameters: + - name: lane_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: MACHINE_CLEANER relay status retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeLaneMachineRelayStatus' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine_cleaner/set: + post: + tags: + - Modules + summary: Set MACHINE_CLEANER relay status for a lane + description: | + Sets the Shelly MACHINE_CLEANER relay state for the lane to on or off and returns the latest status. + operationId: setSelfServeLaneMachineCleanerRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + responses: + '200': + description: MACHINE_CLEANER relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE_CLEANER] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine/set: + post: + tags: + - Modules + summary: Set MACHINE relay status for a lane + description: | + Sets the Shelly MACHINE relay state for the lane to on or off and returns the latest status. + operationId: setSelfServeLaneMachineRelayStatus + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + - on + properties: + lane_id: + type: integer + on: + type: boolean + responses: + '200': + description: MACHINE relay status updated + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE] + requested_on: + type: boolean + relay_id: + type: string + online: + type: boolean + on: + type: boolean + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/relay/machine/enable: + post: + tags: + - Modules + summary: Manually enable MACHINE relay for a lane + description: | + Manually turns on the MACHINE relay for a self-serve lane if and only if the current allowed services + include `MACHINE` (set via `/modules/self-serve/lane/services/allowed`). The relay is never automatically + enabled; an explicit call to this endpoint is required. + operationId: enableSelfServeLaneMachineRelay + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + properties: + lane_id: + type: integer + duration: + type: integer + description: Optional number of seconds after which the relay should automatically turn off + responses: + '200': + description: MACHINE relay enabled + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enabled: + type: boolean + duration: + type: integer + nullable: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + description: Not allowed to enable MACHINE relay (no matching task currently shown) + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /modules/self-serve/lane/force/machine/enable: + post: + tags: + - Modules + summary: Force enable MACHINE relay and mark lane as in-wash (superusers only) + description: | + Superuser/emergency endpoint. Bypasses the allowed services gating and directly turns on the MACHINE relay. + Also ensures the lane is marked as OCCUPIED and IN_WASH with a wash start timestamp if not already set. + operationId: forceEnableSelfServeLaneMachine + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + properties: + lane_id: + type: integer + duration: + type: integer + nullable: true + description: Optional number of seconds after which the relay should automatically turn off + license_plate: + type: string + nullable: true + description: Optional license plate to associate with the lane + responses: + '200': + description: MACHINE relay force-enabled and lane marked in-wash + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + forced: + type: boolean + machine: + type: string + enum: [ENABLED] + duration: + type: integer + nullable: true + status: + type: string + state: + type: string + wash_start_time: + type: integer + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /modules/self-serve/lane/force/machine/disable: + post: + tags: + - Modules + summary: Force disable MACHINE relay but keep lane as in-wash (superusers only) + description: | + Superuser/emergency endpoint. Turns off the MACHINE relay while ensuring the lane remains in an IN_WASH state + (simulating a started wash without machine assistance). + operationId: forceDisableSelfServeLaneMachine + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - lane_id + properties: + lane_id: + type: integer + license_plate: + type: string + nullable: true + responses: + '200': + description: MACHINE relay force-disabled and lane ensured in-wash + content: + application/json: + schema: + type: object + properties: + lane_id: + type: integer + forced: + type: boolean + machine: + type: string + enum: [DISABLED] + status: + type: string + state: + type: string + wash_start_time: + type: integer + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # Module - Other Integration Endpoints + /modules/motorapi/lookup: + get: + tags: + - Modules + summary: Lookup vehicle via MotorAPI + description: Look up vehicle information using license plate + operationId: motorApiLookup + parameters: + - name: plate + in: query + required: true + schema: + type: string + responses: + '200': + description: Vehicle information retrieved successfully + content: + application/json: + schema: {} + + /modules/virkdata/search: + get: + tags: + - Modules + summary: Search VirkData + description: Search for company information in VirkData + operationId: virkdataSearch + parameters: + - name: search + in: query + required: true + schema: + type: string + responses: + '200': + description: Company information retrieved successfully + content: + application/json: + schema: {} + + /modules/fxratesapi/rate: + get: + tags: + - Modules + summary: Get exchange rate + description: Get current exchange rate + operationId: getExchangeRate + parameters: + - name: from + in: query + required: true + schema: + type: string + - name: to + in: query + required: true + schema: + type: string + responses: + '200': + description: Exchange rate retrieved successfully + content: + application/json: + schema: {} + + /modules/fxratesapi/rates: + get: + tags: + - Modules + summary: Get all exchange rates + description: Get all available exchange rates + operationId: getAllExchangeRates + responses: + '200': + description: Exchange rates retrieved successfully + content: + application/json: + schema: {} + + + /modules/weatherapi/current: + get: + tags: + - Modules + summary: Get current weather + description: Get current weather data from WeatherAPI for a location query + operationId: weatherApiCurrent + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Location query (e.g. city, postal code, or latitude,longitude) + responses: + '200': + description: Current weather retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiObjectResponse' + + /modules/weatherapi/forecast: + get: + tags: + - Modules + summary: Get weather forecast + description: Get forecast weather data from WeatherAPI + operationId: weatherApiForecast + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Location query (e.g. city, postal code, or latitude,longitude) + - name: days + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 14 + description: Number of forecast days + responses: + '200': + description: Forecast weather retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiObjectResponse' + + /modules/weatherapi/search: + get: + tags: + - Modules + summary: Search weather locations + description: Search location suggestions from WeatherAPI + operationId: weatherApiSearch + parameters: + - name: q + in: query + required: true + schema: + type: string + description: Search text + responses: + '200': + description: Location search results retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiObjectResponse' + + /modules/workfeed/employees: + get: + tags: + - Modules + summary: List Workfeed employees + description: List employees from Workfeed (`GET /companies/{CompanyID}/employees`) + operationId: workfeedListEmployees + responses: + '200': + description: Workfeed employees retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedEmployeeListResponse' + + /modules/workfeed/employees/{id}: + get: + tags: + - Modules + summary: Get Workfeed employee + description: Retrieve a single Workfeed employee by identifier + operationId: workfeedGetEmployee + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workfeed employee identifier + responses: + '200': + description: Workfeed employee retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedEmployeeSingleResponse' + + /modules/workfeed/shifts: + get: + tags: + - Modules + summary: List Workfeed shifts + description: List Workfeed shifts (`GET /companies/{CompanyID}/shifts`) + operationId: workfeedListShifts + parameters: + - name: startFrom + in: query + required: true + schema: + type: string + format: date-time + description: Only return shifts starting on or after this timestamp (ISO 8601) + - name: startTo + in: query + required: true + schema: + type: string + format: date-time + description: Only return shifts starting before this timestamp (ISO 8601) + - name: employeeID + in: query + required: false + schema: + type: string + description: Filter shifts by employee ID + - name: released + in: query + required: false + schema: + type: boolean + description: Filter by released/published status + responses: + '200': + description: Workfeed shifts retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedShiftListResponse' + + /modules/workfeed/shifts/{id}: + get: + tags: + - Modules + summary: Get Workfeed shift + description: Retrieve a single Workfeed shift by identifier + operationId: workfeedGetShift + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workfeed shift identifier + responses: + '200': + description: Workfeed shift retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedShiftSingleResponse' + + /modules/workfeed/departments: + get: + tags: + - Modules + summary: List Workfeed departments + description: List departments from Workfeed (`GET /companies/{CompanyID}/departments`) + operationId: workfeedListDepartments + responses: + '200': + description: Workfeed departments retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedDepartmentListResponse' + + /departments/weather: + get: + tags: + - Departments + summary: Get department weather timeline + description: Returns hourly weather, washes, Workfeed employee-hours, and productivity status aggregated across selected departments (server local time). Default range is start of yesterday (`00:00`) to end of today (`23:00`). Use `date_from` and `date_to` (`YYYY-MM-DD`) together to override the range. If department coordinates are missing/invalid or WeatherAPI cannot resolve the location, weather data falls back silently and timeline slots default to `mostly_clear`. Slots return `unknown` status when they have no evaluable employee-hours or when one or more selected departments are missing department weather targets. + operationId: getDepartmentWeatherTimeline + parameters: + - name: id + in: query + required: false + schema: + type: array + items: + type: integer + minimum: 1 + minItems: 1 + uniqueItems: true + style: form + explode: true + description: Department ID list. Repeat `id` to select multiple departments (`?id=1&id=2`). + - name: ids + in: query + required: false + schema: + type: string + example: '1,2,3' + description: Optional CSV alternative for department IDs. Merged with `id` if both are provided. At least one of `id` or `ids` must be provided. + - name: date_from + in: query + required: false + schema: + type: string + format: date + example: '2026-03-23' + description: Optional range start date (`YYYY-MM-DD`). Must be used together with `date_to`. + - name: date_to + in: query + required: false + schema: + type: string + format: date + example: '2026-03-24' + description: Optional range end date (`YYYY-MM-DD`, inclusive). Must be used together with `date_from`. + responses: + '200': + description: Department weather timeline retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWeatherTimelineResponse' + example: + success: true + meta: [] + includes: [] + data: + - date: '2026-03-23' + time: '00:00' + current: false + weather: mostly_cloudy + washes: 1 + hours: 2.0 + status: degraded + - date: '2026-03-24' + time: '13:00' + current: true + weather: rain + washes: 0 + hours: 2.5 + status: unhealthy + - date: '2026-03-24' + time: '14:00' + current: false + weather: mostly_clear + washes: 0 + hours: 1.0 + status: unknown + + /departments/weather/targets: + get: + tags: + - Departments + summary: Get department weather status targets + description: Returns department-specific weather productivity thresholds used by `/departments/weather` to classify `healthy`, `degraded`, and `unhealthy` statuses. + operationId: getDepartmentWeatherTargets + parameters: + - name: id + in: query + required: false + schema: + type: array + items: + type: integer + minimum: 1 + minItems: 1 + uniqueItems: true + style: form + explode: true + description: Department ID list. Repeat `id` to select multiple departments (`?id=1&id=2`). + - name: ids + in: query + required: false + schema: + type: string + example: '1,2,3' + description: Optional CSV alternative for department IDs. Merged with `id` if both are provided. At least one of `id` or `ids` must be provided. + responses: + '200': + description: Department weather targets retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWeatherTargetsResponse' + example: + success: true + meta: [] + includes: [] + data: + - department_id: 1 + degraded_threshold: 1.0 + healthy_threshold: 1.3 + configured: true + - department_id: 2 + degraded_threshold: null + healthy_threshold: null + configured: false + put: + tags: + - Departments + summary: Upsert department weather status targets + description: Creates or updates the weather productivity thresholds for one department. `healthy_threshold` must be greater than or equal to `degraded_threshold`. + operationId: upsertDepartmentWeatherTarget + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DepartmentWeatherTargetUpsertRequest' + example: + department_id: 1 + degraded_threshold: 1.0 + healthy_threshold: 1.3 + responses: + '200': + description: Department weather targets updated successfully + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/DepartmentWeatherTarget' + required: [data] + example: + success: true + meta: [] + includes: [] + data: + department_id: 1 + degraded_threshold: 1.0 + healthy_threshold: 1.3 + configured: true + + /modules/entra/users: + get: + tags: + - Modules + summary: List Microsoft Entra users + description: Get list of users from Microsoft Entra (Azure AD) + operationId: listEntraUsers + responses: + '200': + description: Entra users retrieved successfully + content: + application/json: + schema: {} + + # Attachments Endpoints + /attachments/upload: + post: + tags: + - Attachments + summary: Upload attachment + description: Upload a file attachment + operationId: uploadAttachment + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '201': + description: Attachment uploaded successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + + /orders/attachments: + get: + tags: + - Attachments + summary: List order attachments + description: Get attachments for an order + operationId: listOrderAttachments + parameters: + - name: order_id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Order attachments retrieved successfully + content: + application/json: + schema: {} + + /orders/attachments/upload: + post: + tags: + - Attachments + summary: Upload order attachment + description: Upload an attachment to an order + operationId: uploadOrderAttachment + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + order_id: + type: integer + file: + type: string + format: binary + responses: + '201': + description: Order attachment uploaded successfully + content: + application/json: + schema: {} + + /orders/attachments/download: + get: + tags: + - Attachments + summary: Download order attachment + description: Download a specific order attachment + operationId: downloadOrderAttachment + parameters: + - name: id + in: query + required: true + schema: + type: integer + responses: + '200': + description: Attachment downloaded successfully + content: + application/octet-stream: + schema: + type: string + format: binary + + # Forms Endpoints + /form: + get: + tags: + - Forms + summary: Get form + description: Retrieve a form definition + operationId: getForm + parameters: + - name: id + in: query + schema: + type: integer + responses: + '200': + description: Form retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Forms + summary: Submit form + description: Submit a form + operationId: submitForm + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, data] + properties: + id: + type: string + description: Form identifier + data: + type: object + description: Form submission data + g_recaptcha_response: + type: string + description: reCAPTCHA verification token (required if not authenticated) + responses: + '201': + description: Form submitted successfully + content: + application/json: + schema: {} + + # Permissions Endpoints + /permissions: + get: + tags: + - Users + summary: List permissions + description: Get list of all available permissions + operationId: listPermissions + responses: + '200': + description: Permissions retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Permission' + + # Customer Management Endpoints + /customer/attributes: + get: + tags: + - Users + summary: Get customer attributes + description: Get custom attributes for a customer + operationId: getCustomerAttributes + parameters: + - name: customer_id + in: query + schema: + type: integer + responses: + '200': + description: Customer attributes retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer attribute + description: Add a custom attribute to a customer + operationId: addCustomerAttribute + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '201': + description: Customer attribute added successfully + content: + application/json: + schema: {} + delete: + tags: + - Users + summary: Delete customer attribute + description: Remove a custom attribute from a customer + operationId: deleteCustomerAttribute + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Customer attribute deleted successfully + content: + application/json: + schema: {} + + /customer/notes: + get: + tags: + - Users + summary: Get customer notes + description: Get notes for a customer + operationId: getCustomerNotes + parameters: + - name: customer_id + in: query + schema: + type: integer + responses: + '200': + description: Customer notes retrieved successfully + content: + application/json: + schema: {} + post: + tags: + - Users + summary: Add customer note + description: Add a note to a customer + operationId: addCustomerNote + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '201': + description: Customer note added successfully + content: + application/json: + schema: {} + delete: + tags: + - Users + summary: Delete customer note + description: Remove a note from a customer + operationId: deleteCustomerNote + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Customer note deleted successfully + content: + application/json: + schema: {} + + /customers/search: + post: + tags: + - Users + summary: Search customers + description: Search for customers using various criteria + operationId: searchCustomers + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + query: + type: string + responses: + '200': + description: Customers found successfully + content: + application/json: + schema: {} + + /search/system: + get: + tags: + - Search + summary: System-wide search + description: Search across all supported entities with permission-aware filtering and optional intent parsing debug metadata. Customer records and customer-related entities are matched against a local e-conomic customer index (name/address/email/CVR) that is refreshed by cron. Intent parsing is invoked adaptively when lexical confidence is low or when the query looks intent-driven. Results are ordered by relevance, with recent records preferred when relevance is comparable. + operationId: systemWideSearchGet + parameters: + - in: query + name: query + required: true + schema: + type: string + description: Free-text query to search for. Supports natural-language intent fallback and domain synonyms such as `rabat` -> `discount`. + - in: query + name: include_types + required: false + schema: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + style: form + explode: false + description: Comma-separated list of entity types to include. Defaults to all allowed types. + - in: query + name: exclude_types + required: false + schema: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + style: form + explode: false + description: Comma-separated list of entity types to exclude. + - in: query + name: include_associations + required: false + schema: + type: boolean + default: true + description: Include associated objects when matching a primary entity such as a customer. + - in: query + name: debug_intent + required: false + schema: + type: boolean + default: false + description: Include intent parser diagnostics in `meta.intent_parser`. + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + default: 0 + responses: + '200': + description: Search results returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: + - Search + summary: System-wide search + description: Search across all supported entities using JSON request payload. Customer-related matches include local e-conomic customer index fields. Intent parsing may run adaptively for intent-driven natural-language queries. Results are ordered by relevance, with recent records preferred when relevance is comparable. + operationId: systemWideSearchPost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchRequest' + responses: + '200': + description: Search results returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/search/system/cache: + delete: + tags: + - Search + summary: Clear system search caches + description: Clears both query-result cache and intent-parser cache namespaces for system-wide search. + operationId: clearSystemSearchCache + responses: + '200': + description: Cache cleared successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchCacheClearResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /superuser/search/system/cache/rebuild: + post: + tags: + - Search + summary: Queue system search cache rebuild + description: Queues a cache rebuild request and clears active query/intent cache namespaces immediately. + operationId: rebuildSystemSearchCache + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchCacheRebuildRequest' + responses: + '200': + description: Cache rebuild queued successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SystemSearchCacheRebuildResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + # Configuration Endpoints + /economic/config: + get: + tags: [Config] + summary: Get e-conomic config + operationId: getEconomicConfig + responses: + '200': + description: e-conomic configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EconomicConfigListResponse' + post: + tags: [Config] + summary: Update e-conomic config + operationId: updateEconomicConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: e-conomic configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /reCAPTCHA/config: + get: + tags: [Config] + summary: Get reCAPTCHA config + operationId: getRecaptchaModuleConfig + responses: + '200': + description: reCAPTCHA configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/RecaptchaConfigListResponse' + post: + tags: [Config] + summary: Update reCAPTCHA config + operationId: updateRecaptchaConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: reCAPTCHA configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /email/config: + get: + tags: [Config] + summary: Get email config + operationId: getEmailConfig + responses: + '200': + description: Email configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmailConfigListResponse' + post: + tags: [Config] + summary: Update email config + operationId: updateEmailConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Email configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /email/config/test: + post: + tags: [Config] + summary: Test email config + operationId: testEmailConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Email configuration test completed + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigTestResponse' + + /backups/config: + get: + tags: [Config] + summary: Get backups config + operationId: getBackupsConfig + responses: + '200': + description: Backups configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BackupsConfigListResponse' + post: + tags: [Config] + summary: Update backups config + operationId: updateBackupsConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Backups configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /bird/config: + get: + tags: [Config] + summary: Get Bird config + operationId: getBirdConfig + responses: + '200': + description: Bird configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BirdConfigListResponse' + post: + tags: [Config] + summary: Update Bird config + operationId: updateBirdConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Bird configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /motorapi/config: + get: + tags: [Config] + summary: Get MotorAPI config + operationId: getMotorApiConfig + responses: + '200': + description: MotorAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MotorApiConfigListResponse' + post: + tags: [Config] + summary: Update MotorAPI config + operationId: updateMotorApiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: MotorAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /stripe/config: + get: + tags: [Config] + summary: Get Stripe config + operationId: getStripeConfig + responses: + '200': + description: Stripe configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/StripeConfigListResponse' + post: + tags: [Config] + summary: Update Stripe config + operationId: updateStripeConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Stripe configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /fxratesapi/config: + get: + tags: [Config] + summary: Get FXRatesAPI config + operationId: getFxRatesApiConfig + responses: + '200': + description: FXRatesAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/FxRatesApiConfigListResponse' + post: + tags: [Config] + summary: Update FXRatesAPI config + operationId: updateFxRatesApiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: FXRatesAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + + /weatherapi/config: + get: + tags: [Config] + summary: Get WeatherAPI config + operationId: getWeatherApiConfig + responses: + '200': + description: WeatherAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WeatherApiConfigListResponse' + post: + tags: [Config] + summary: Update WeatherAPI config + operationId: updateWeatherApiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: WeatherAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /workfeed/config: + get: + tags: [Config] + summary: Get Workfeed config + operationId: getWorkfeedConfig + responses: + '200': + description: Workfeed configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WorkfeedConfigListResponse' + examples: + default: + summary: Workfeed module configuration + value: + success: true + data: + - module: workfeed + variable: enabled + type: bool + value: true + - module: workfeed + variable: api_url + type: string + value: https://europe-west1-production-eu-327a3.cloudfunctions.net/api + - module: workfeed + variable: api_key + type: string + value: wf_live_xxxxxxxxxxxxxxxxx + - module: workfeed + variable: CompanyID + type: string + value: "123456" + meta: [] + includes: [] + post: + tags: [Config] + summary: Update Workfeed config + operationId: updateWorkfeedConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Workfeed configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /gatewayapi/config: + get: + tags: [Config] + summary: Get GatewayAPI config + operationId: getGatewayApiConfig + responses: + '200': + description: GatewayAPI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GatewayApiConfigListResponse' + post: + tags: [Config] + summary: Update GatewayAPI config + operationId: updateGatewayApiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: GatewayAPI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /xlvask/config: + get: + tags: [Config] + summary: Get XLVask config + operationId: getXlvaskConfig + responses: + '200': + description: XLVask configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/XlvaskConfigListResponse' + post: + tags: [Config] + summary: Update XLVask config + operationId: updateXlvaskConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: XLVask configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /entra/config: + get: + tags: [Config] + summary: Get Entra config + operationId: getEntraConfig + responses: + '200': + description: Entra configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EntraConfigListResponse' + post: + tags: [Config] + summary: Update Entra config + operationId: updateEntraConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Entra configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /limble/config: + get: + tags: [Config] + summary: Get Limble config + operationId: getLimbleConfig + responses: + '200': + description: Limble configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/LimbleConfigListResponse' + post: + tags: [Config] + summary: Update Limble config + operationId: updateLimbleConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Limble configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /ocrspace/config: + get: + tags: [Config] + summary: Get OcrSpace config + operationId: getOcrSpaceConfig + responses: + '200': + description: OcrSpace configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/OcrSpaceConfigListResponse' + post: + tags: [Config] + summary: Update OcrSpace config + operationId: updateOcrSpaceConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: OcrSpace configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /openai/config: + get: + tags: [Config] + summary: Get OpenAI config + operationId: getOpenAiConfig + responses: + '200': + description: OpenAI configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAiConfigListResponse' + post: + tags: [Config] + summary: Update OpenAI config + operationId: updateOpenAiConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: OpenAI configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /licenseplaterecognizer/config: + get: + tags: [Config] + summary: Get LicensePlateRecognizer config + operationId: getLicensePlateRecognizerConfig + responses: + '200': + description: LicensePlateRecognizer configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/LicensePlateRecognizerConfigListResponse' + post: + tags: [Config] + summary: Update LicensePlateRecognizer config + operationId: updateLicensePlateRecognizerConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: LicensePlateRecognizer configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /virkdata/config: + get: + tags: [Config] + summary: Get Virkdata config + operationId: getVirkdataConfig + responses: + '200': + description: Virkdata configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/VirkdataConfigListResponse' + post: + tags: [Config] + summary: Update Virkdata config + operationId: updateVirkdataConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Virkdata configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /shelly/config: + get: + tags: [Config] + summary: Get Shelly config + operationId: getShellyConfig + responses: + '200': + description: Shelly configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ShellyConfigListResponse' + post: + tags: [Config] + summary: Update Shelly config + operationId: updateShellyConfig + requestBody: + required: false + content: + application/json: + schema: {} + responses: + '200': + description: Shelly configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /selfserve/config: + get: + tags: [Config] + summary: Get Self-Serve config + operationId: getSelfServeConfig + responses: + '200': + description: Self-serve configuration retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeConfigListResponse' + post: + tags: [Config] + summary: Update Self-Serve config + operationId: updateSelfServeConfig + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SelfServeConfig' + responses: + '200': + description: Self-serve configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ModuleConfigUpdateResponse' + + /branding: + get: + tags: + - Branding + summary: List branding options + description: Retrieve a list of branding options or a specific branding option if ID is provided + operationId: listBrandingOptions + parameters: + - name: id + in: query + required: false + schema: + type: integer + - $ref: '#/components/parameters/PageParam' + - $ref: '#/components/parameters/PerPageParam' + responses: + '200': + description: Branding options retrieved successfully + content: + application/json: + schema: {} + '400': + $ref: '#/components/responses/BadRequest' + '403': + $ref: '#/components/responses/Forbidden' + post: + tags: + - Branding + summary: Add branding option + description: Create a new branding option + operationId: addBrandingOption + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name, description, cvr] + properties: + name: {type: string} + description: {type: string} + cvr: {type: integer} + responses: + '200': + description: Branding option added successfully + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + put: + tags: + - Branding + summary: Edit branding option + description: Update an existing branding option + operationId: editBrandingOption + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: {type: integer} + name: {type: string} + description: {type: string} + cvr: {type: integer} + responses: + '200': + description: Branding option updated successfully + content: + application/json: + schema: {} + '403': + $ref: '#/components/responses/Forbidden' + + /roles: + get: + tags: + - Roles + summary: List roles + operationId: listRoles + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Roles + summary: Add role + operationId: addRole + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + put: + tags: + - Roles + summary: Edit role + operationId: editRole + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, name] + properties: + id: {type: integer} + name: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /roles/permissions: + post: + tags: + - Roles + summary: Add permission to role + operationId: addRolePermission + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role_id, permission] + properties: + role_id: {type: integer} + permission: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + delete: + tags: + - Roles + summary: Remove permission from role + operationId: removeRolePermission + parameters: + - name: role_id + in: query + required: true + schema: {type: integer} + - name: permission + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /roles/clone: + post: + tags: + - Roles + summary: Clone role + operationId: cloneRole + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role_id, name] + properties: + role_id: {type: integer} + name: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/washcertificates: + get: + tags: + - Modules + summary: List wash certificates + operationId: listWashCertificates + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/xlvask/services/usage/orders: + get: + tags: + - Modules + summary: Get XLVask usage orders + operationId: getXlvaskUsageOrders + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /modules/xlvask/services/usage/orders/fast-link: + get: + tags: + - Modules + summary: Get XLVask usage orders fast link + operationId: getXlvaskUsageOrdersFastLink + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/department: + get: + tags: + - Departments + summary: List departments (superuser) + operationId: listSuperuserDepartments + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/department/prices: + get: + tags: + - Departments + summary: Get department prices + operationId: getDepartmentPrices + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Departments + summary: Set department price + operationId: setDepartmentPrice + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, product_id, price] + properties: + department_id: {type: integer} + product_id: {type: integer} + price: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /superuser/department/variables: + get: + tags: + - Departments + summary: Get department variables + operationId: getDepartmentVariables + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Departments + summary: Set department variable + operationId: setDepartmentVariable + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, variable, value] + properties: + department_id: {type: integer} + variable: {type: string} + value: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/daily-reports: + get: + tags: + - Departments + summary: List daily reports + operationId: listDailyReports + responses: + '200': + description: Success + content: + application/json: + schema: {} + post: + tags: + - Departments + summary: Add daily report + operationId: addDailyReport + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [department_id, date, report] + properties: + department_id: {type: integer} + date: {type: string} + report: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + put: + tags: + - Departments + summary: Edit daily report + operationId: editDailyReport + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, report] + properties: + id: {type: integer} + report: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/daily-reports/get: + get: + tags: + - Departments + summary: Get daily report + operationId: getDailyReport + parameters: + - name: department_id + in: query + required: true + schema: {type: integer} + - name: date + in: query + required: true + schema: {type: string} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/daily-reports/product-count: + get: + tags: + - Departments + summary: Get product count for daily reports + operationId: getDailyReportProductCount + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: date_to + in: query + required: false + schema: {type: string} + - name: department_id + in: query + required: true + schema: {type: integer} + - name: product_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/daily-reports/transaction-count: + get: + tags: + - Departments + summary: Get transaction count for daily reports + operationId: getDailyReportTransactionCount + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: date_to + in: query + required: false + schema: {type: string} + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + /departments/daily-reports/bookings-count: + get: + tags: + - Departments + summary: Get bookings count for daily reports + operationId: getDailyReportBookingsCount + parameters: + - name: date + in: query + required: true + schema: {type: string} + - name: department_id + in: query + required: true + schema: {type: integer} + responses: + '200': + description: Success + content: + application/json: + schema: {} + + # Account Security - Passkeys + /account/security/passkeys: + get: + tags: + - Security + summary: List passkeys for the authenticated user + operationId: listPasskeys + responses: + '200': + description: A list of passkeys + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Passkey' + '400': + description: Invalid session or request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + tags: + - Security + summary: Create/add a passkey for the authenticated user + operationId: createPasskey + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasskeyCreateRequest' + responses: + '200': + description: Passkey created + content: + application/json: + schema: + type: object + properties: + id: + type: integer + '400': + description: Invalid session or request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /account/security/passkeys/{id}: + patch: + tags: + - Security + summary: Rename a passkey + operationId: renamePasskey + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PasskeyRenameRequest' + responses: + '200': + description: Passkey renamed + content: + application/json: + schema: {} + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + delete: + tags: + - Security + summary: Delete a passkey + operationId: deletePasskey + parameters: + - in: path + name: id + required: true + schema: + type: integer + responses: + '200': + description: Passkey deleted + content: + application/json: + schema: {} + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT token obtained from /auth/login or /auth/employee/login + + parameters: + PageParam: + name: page + in: query + description: Page number for pagination + schema: + type: integer + minimum: 1 + default: 1 + PerPageParam: + name: per_page + in: query + description: Number of items per page + schema: + type: integer + minimum: 1 + maximum: 100 + default: 30 + SearchParam: + name: search + in: query + description: Search query string + schema: + type: string + LimitParam: + name: limit + in: query + description: Number of items per page + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + FiltersParam: + name: filters + in: query + description: Filters for the list (e.g., module:selfserve,status_code:200) + schema: + type: string + XCustomerNumber: + name: X-Customer-Number + in: header + required: false + description: | + Target customer number for subuser requests. Ignored for classic user sessions. + Required on customer-scoped endpoints when authenticated as a subuser unless + the target customer can be inferred from context. + schema: + type: integer + + responses: + BadRequest: + description: Bad request - Invalid input parameters + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Unauthorized: + description: Unauthorized - Invalid or missing authentication token + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + Forbidden: + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + NotFound: + description: Not found - Resource does not exist + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + schemas: + Error: + type: object + properties: + error: + type: string + description: Error message + code: + type: integer + description: HTTP status code + + SystemSearchEntityType: + type: string + enum: + - objects + - module_config + - orders + - order_items + - customers + - employees + - users + - subusers + - customer_discounts + - customer_fixed_prices + - departments + - permissions + - roles + - invoices + - vehicles + - bookings + - bookings_new + - branding + - categories + - currency_conversion_rates + - customer_codes + - customer_default_department + - customer_notes + - customer_vehicles_addons + - department_categories + - department_daily_reports + - department_gates + - department_goals + - department_lanes + - department_notification_sms + - department_relays + - department_selfserve_condition_rules + - department_selfserve_conditions + - department_selfserve_questions + - department_selfserve_tasks + - department_selfserve_vehicle_conditions + - department_time_bookings_entries + - department_time_bookings_opening_hours + - department_time_bookings_types + - department_variables + - fxratesapi_conversion_rates + - module_action_logs + - motorapi_lookups + - notifications + - order_bookings + - plate_scanners + - plate_scans + - product_options + - products + - stripe_module_customers + - stripe_module_orders + - stripe_payment_intents + - subuser_grants + - xlvask_customers + - xlvask_potential_order_matches + - xlvask_usage_log_wash_items + - xlvask_usage_logs + - xlvask_vehicle_types + - xlvask_vehicles + + SystemSearchRequest: + type: object + required: + - query + properties: + query: + type: string + description: Free-text query to search for. Customer lookups include local e-conomic index fields and lexical synonym expansion (for example `rabat` -> `discount`). + include_types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + description: Limit search to these entity types. + exclude_types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + description: Exclude these entity types from search. + include_associations: + type: boolean + default: true + description: Include associated records for matched core entities. + debug_intent: + type: boolean + default: false + description: Include parser diagnostics in `meta.intent_parser`. + limit: + type: integer + minimum: 1 + maximum: 200 + default: 50 + offset: + type: integer + minimum: 0 + default: 0 + + SystemSearchResult: + type: object + properties: + entity_type: + $ref: '#/components/schemas/SystemSearchEntityType' + entity_id: + type: string + title: + type: string + description: + type: string + customer_number: + type: integer + nullable: true + department_id: + type: integer + nullable: true + score: + type: integer + association_reason: + type: string + nullable: true + payload: + type: object + additionalProperties: true + required: + - entity_type + - entity_id + - title + - score + + SystemSearchIntentParserMeta: + type: object + properties: + invoked: + type: boolean + source: + type: string + enum: [cache, openai, none] + status: + type: string + confidence: + type: number + minimum: 0 + maximum: 1 + expanded_terms: + type: array + items: + type: string + entity_hints: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + fallback_reason: + type: string + nullable: true + required: + - invoked + - source + - status + - confidence + - expanded_terms + - entity_hints + + SystemSearchMeta: + type: object + properties: + query: + type: string + limit: + type: integer + offset: + type: integer + total: + type: integer + allowed_types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + cache: + type: object + properties: + hit: + type: boolean + required: [hit] + intent_parser: + $ref: '#/components/schemas/SystemSearchIntentParserMeta' + required: + - query + - limit + - offset + - total + - allowed_types + - cache + + SystemSearchPayload: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/SystemSearchResult' + grouped_results: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/SystemSearchResult' + meta: + $ref: '#/components/schemas/SystemSearchMeta' + required: + - results + - grouped_results + - meta + + SystemSearchResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/SystemSearchPayload' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + SystemSearchCacheClearResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + query_cache_cleared: + type: boolean + intent_cache_cleared: + type: boolean + required: + - message + - query_cache_cleared + - intent_cache_cleared + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + SystemSearchCacheRebuildRequest: + type: object + properties: + scope: + type: string + enum: [all, types, dirty] + default: all + types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + + SystemSearchCacheRebuildResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + request: + type: object + properties: + scope: + type: string + enum: [all, types, dirty] + types: + type: array + items: + $ref: '#/components/schemas/SystemSearchEntityType' + requested_at: + type: integer + required: + - scope + - types + - requested_at + query_cache_cleared: + type: boolean + intent_cache_cleared: + type: boolean + required: + - message + - request + - query_cache_cleared + - intent_cache_cleared + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + ModuleConfigValue: + oneOf: + - type: string + - type: integer + - type: number + - type: boolean + - type: array + items: {} + - type: object + additionalProperties: true + nullable: true + + ModuleConfigEnvelopeBase: + type: object + properties: + success: + type: boolean + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - meta + - includes + + EconomicConfigEntry: + type: object + properties: + module: { type: string, enum: [economic] } + variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber] } + type: { type: string, enum: [string, int] } + value: + oneOf: + - type: string + - type: integer + required: [module, variable, type, value] + + RecaptchaConfigEntry: + type: object + properties: + module: { type: string, enum: [reCAPTCHA] } + variable: { type: string, enum: [enabled, secret_key_v2, site_key_v2] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + EmailConfigEntry: + type: object + properties: + module: { type: string, enum: [Email] } + variable: + type: string + enum: [enabled, mailersend_api_key, mailersend_enabled, smtp_encryption, smtp_from, smtp_from_name, smtp_host, smtp_password, smtp_port, smtp_reply_to, smtp_reply_to_name, smtp_username] + type: { type: string, enum: [bool, string, int] } + value: + oneOf: + - type: boolean + - type: string + - type: integer + required: [module, variable, type, value] + + BackupsConfigEntry: + type: object + properties: + module: { type: string, enum: [Backups] } + variable: { type: string, enum: [enabled] } + type: { type: string, enum: [bool] } + value: { type: boolean } + required: [module, variable, type, value] + + BirdConfigEntry: + type: object + properties: + module: { type: string, enum: [bird] } + variable: { type: string, enum: [api_key, enabled, server_url, workplaceId, channelId] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + MotorApiConfigEntry: + type: object + properties: + module: { type: string, enum: [motorapi] } + variable: { type: string, enum: [daily_limit, enabled, secret_key] } + type: { type: string, enum: [int, bool, string] } + value: + oneOf: + - type: integer + - type: boolean + - type: string + required: [module, variable, type, value] + + StripeConfigEntry: + type: object + properties: + module: { type: string, enum: [Stripe] } + variable: { type: string, enum: [economic_customer_number, enabled, publishable_key, secret_key] } + type: { type: string, enum: [int, bool, string] } + value: + oneOf: + - type: integer + - type: boolean + - type: string + required: [module, variable, type, value] + + FxRatesApiConfigEntry: + type: object + properties: + module: { type: string, enum: [fxratesapi] } + variable: { type: string, enum: [daily_limit, enabled, secret_key] } + type: { type: string, enum: [int, bool, string] } + value: + oneOf: + - type: integer + - type: boolean + - type: string + required: [module, variable, type, value] + + + WeatherApiConfigEntry: + type: object + properties: + module: { type: string, enum: [weatherapi] } + variable: { type: string, enum: [enabled, secret_key] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + WorkfeedConfigEnabledEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [enabled] } + type: { type: string, enum: [bool] } + value: { type: boolean } + required: [module, variable, type, value] + + WorkfeedConfigApiUrlEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [api_url] } + type: { type: string, enum: [string] } + value: { type: string, example: "https://europe-west1-production-eu-327a3.cloudfunctions.net/api" } + required: [module, variable, type, value] + + WorkfeedConfigApiKeyEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [api_key] } + type: { type: string, enum: [string] } + value: { type: string, example: "wf_live_xxxxxxxxxxxxxxxxx" } + required: [module, variable, type, value] + + WorkfeedConfigCompanyIdEntry: + type: object + properties: + module: { type: string, enum: [workfeed] } + variable: { type: string, enum: [CompanyID] } + type: { type: string, enum: [string] } + value: { type: string, example: "123456" } + required: [module, variable, type, value] + + WorkfeedConfigEntry: + oneOf: + - $ref: '#/components/schemas/WorkfeedConfigEnabledEntry' + - $ref: '#/components/schemas/WorkfeedConfigApiUrlEntry' + - $ref: '#/components/schemas/WorkfeedConfigApiKeyEntry' + - $ref: '#/components/schemas/WorkfeedConfigCompanyIdEntry' + discriminator: + propertyName: variable + mapping: + enabled: '#/components/schemas/WorkfeedConfigEnabledEntry' + api_url: '#/components/schemas/WorkfeedConfigApiUrlEntry' + api_key: '#/components/schemas/WorkfeedConfigApiKeyEntry' + CompanyID: '#/components/schemas/WorkfeedConfigCompanyIdEntry' + + GatewayApiConfigEntry: + type: object + properties: + module: { type: string, enum: [GatewayAPI] } + variable: { type: string, enum: [api_key, api_secret, api_token, enabled, sender] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + XlvaskConfigEntry: + type: object + properties: + module: { type: string, enum: [xlvask] } + variable: { type: string, enum: [enabled, password, synchronization_enabled, username] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + EntraConfigEntry: + type: object + properties: + module: { type: string, enum: [Entra] } + variable: { type: string, enum: [enabled, entra_client_id, entra_client_secret, entra_tenant_id] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + LimbleConfigEntry: + type: object + properties: + module: { type: string, enum: [limble] } + variable: { type: string, enum: [client_id, client_secret, enabled, webhooks_enabled] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + OcrSpaceConfigEntry: + type: object + properties: + module: { type: string, enum: [ocrSpace] } + variable: { type: string, enum: [api_key, enabled] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + OpenAiConfigEntry: + type: object + properties: + module: { type: string, enum: [openAI] } + variable: { type: string, enum: [api_key, enabled] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + LicensePlateRecognizerConfigEntry: + type: object + properties: + module: { type: string, enum: [licenseplaterecognizer] } + variable: { type: string, enum: [api_key, enabled] } + type: { type: string, enum: [string, bool] } + value: + oneOf: + - type: string + - type: boolean + required: [module, variable, type, value] + + VirkdataConfigEntry: + type: object + properties: + module: { type: string, enum: [virkdata] } + variable: { type: string, enum: [enabled, monthly_limit, secret_key] } + type: { type: string, enum: [bool, int, string] } + value: + oneOf: + - type: boolean + - type: integer + - type: string + required: [module, variable, type, value] + + ShellyConfigEntry: + type: object + properties: + module: { type: string, enum: [shelly] } + variable: { type: string, enum: [enabled, secret_key, server_url] } + type: { type: string, enum: [bool, string] } + value: + oneOf: + - type: boolean + - type: string + required: [module, variable, type, value] + + SelfServeConfigEntry: + type: object + properties: + module: { type: string, enum: [selfserve] } + variable: { type: string, enum: [enabled, minute_product, machine_wash_minutes_included] } + type: { type: string, enum: [bool, int] } + value: + oneOf: + - type: boolean + - type: integer + required: [module, variable, type, value] + + EconomicConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/EconomicConfigEntry' } } + required: [data] + + RecaptchaConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/RecaptchaConfigEntry' } } + required: [data] + + EmailConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/EmailConfigEntry' } } + required: [data] + + BackupsConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/BackupsConfigEntry' } } + required: [data] + + BirdConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/BirdConfigEntry' } } + required: [data] + + MotorApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/MotorApiConfigEntry' } } + required: [data] + + StripeConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/StripeConfigEntry' } } + required: [data] + + FxRatesApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/FxRatesApiConfigEntry' } } + required: [data] + + + WeatherApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WeatherApiConfigEntry' } } + required: [data] + + WorkfeedConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/WorkfeedConfigEntry' } } + required: [data] + example: + success: true + data: + - module: workfeed + variable: enabled + type: bool + value: true + - module: workfeed + variable: api_url + type: string + value: https://europe-west1-production-eu-327a3.cloudfunctions.net/api + - module: workfeed + variable: api_key + type: string + value: wf_live_xxxxxxxxxxxxxxxxx + - module: workfeed + variable: CompanyID + type: string + value: "123456" + meta: [] + includes: [] + + GatewayApiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/GatewayApiConfigEntry' } } + required: [data] + + XlvaskConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/XlvaskConfigEntry' } } + required: [data] + + EntraConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/EntraConfigEntry' } } + required: [data] + + LimbleConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/LimbleConfigEntry' } } + required: [data] + + OcrSpaceConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/OcrSpaceConfigEntry' } } + required: [data] + + OpenAiConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/OpenAiConfigEntry' } } + required: [data] + + LicensePlateRecognizerConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/LicensePlateRecognizerConfigEntry' } } + required: [data] + + VirkdataConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/VirkdataConfigEntry' } } + required: [data] + + ShellyConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/ShellyConfigEntry' } } + required: [data] + + SelfServeConfigListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/SelfServeConfigEntry' } } + required: [data] + + + DepartmentWeatherStatus: + type: string + description: Productivity health for the slot. `unknown` is returned when the slot has not started yet, has no employee-hours, or one or more selected departments are missing department weather targets. + enum: [unknown, healthy, degraded, unhealthy] + + DepartmentWeatherCondition: + type: string + enum: [clear, mostly_clear, partly_cloudy, mostly_cloudy, overcast, rain, showers, thunderstorm, snow, fog] + + DepartmentWeatherTarget: + type: object + properties: + department_id: + type: integer + minimum: 1 + degraded_threshold: + type: number + nullable: true + minimum: 0 + description: Minimum washes per hour ratio required for `degraded`. `null` when target is not configured. + healthy_threshold: + type: number + nullable: true + minimum: 0 + description: Minimum washes per hour ratio required for `healthy`. `null` when target is not configured. + configured: + type: boolean + description: Whether both weather status thresholds are configured and valid for the department. + required: [department_id, degraded_threshold, healthy_threshold, configured] + + DepartmentWeatherTargetUpsertRequest: + type: object + required: [department_id, degraded_threshold, healthy_threshold] + properties: + department_id: + type: integer + minimum: 1 + degraded_threshold: + type: number + minimum: 0 + healthy_threshold: + type: number + minimum: 0 + description: Must be greater than or equal to `degraded_threshold`. + + DepartmentWeatherTimelineEntry: + type: object + properties: + date: + type: string + format: date + description: Calendar date for the hourly slot (`YYYY-MM-DD`). + example: '2026-03-24' + time: + type: string + description: Hour label for the slot in 24-hour format (`HH:00`). + example: '01:00' + current: + type: boolean + description: True when this slot matches the current server hour. + example: false + weather: + $ref: '#/components/schemas/DepartmentWeatherCondition' + washes: + type: integer + minimum: 0 + example: 0 + hours: + type: number + format: float + minimum: 0 + example: 2.5 + description: Sum of Workfeed employee-hours in the department for this exact hour slot + status: + $ref: '#/components/schemas/DepartmentWeatherStatus' + required: [date, time, current, weather, washes, hours, status] + + WeatherApiObjectResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: object + additionalProperties: true + required: [data] + + WorkfeedDepartment: + type: object + properties: + id: { type: string, example: "PKHaOSgFA4uguOqmLfWv" } + name: { type: string, example: "API Testing Account 😎" } + isDeleted: { type: boolean, example: false } + createTime: { type: string, format: date-time, example: "2023-10-25T09:43:06.650Z" } + updateTime: { type: string, format: date-time, example: "2023-10-25T09:43:06.650Z" } + additionalProperties: true + + WorkfeedEmployee: + type: object + properties: + id: { type: string, example: "J9QAIiTG0nRC1OsC5YvHfLWdDHn1" } + firstname: { type: string, example: "API 2" } + lastname: { type: string, example: "Test 2" } + email: { type: string, nullable: true, example: "test2@example.com" } + phone: { type: string, nullable: true, example: "12345678" } + roleIDs: + type: array + items: { type: string } + example: ["hLbEKIPTlMh3ehotXl0w"] + departmentIDs: + type: array + items: { type: string } + example: ["PKHaOSgFA4uguOqmLfWv"] + primaryDepartmentID: { type: string, nullable: true } + street: { type: string, nullable: true, example: "" } + city: { type: string, nullable: true, example: "" } + zip: { type: string, nullable: true, example: "" } + accessLevel: { type: string, nullable: true, example: "employee" } + wage: { type: number, nullable: true } + minHours: { type: number, nullable: true } + maxHours: { type: number, nullable: true } + isDeleted: { type: boolean, example: false } + ssn: { type: string, nullable: true, example: "" } + imageURL: { type: string, nullable: true, format: uri } + createTime: { type: string, format: date-time, example: "2023-10-28T18:22:45.695Z" } + updateTime: { type: string, format: date-time, example: "2023-10-28T18:34:37.191Z" } + additionalProperties: true + + WorkfeedShiftComment: + type: object + properties: + message: { type: string, nullable: true, example: "Comments! 😍" } + creatorID: { type: string, nullable: true, example: "API" } + createdOn: { type: string, format: date-time, nullable: true, example: "2023-10-27T09:43:36.542Z" } + additionalProperties: true + + WorkfeedShiftCustomBreak: + type: object + properties: + creatorID: { type: string, nullable: true, example: "automatic" } + duration: { type: number, nullable: true, example: 1 } + createdOn: { type: string, format: date-time, nullable: true, example: "2023-10-27T09:10:30.336Z" } + additionalProperties: true + + WorkfeedShiftApproval: + type: object + properties: + approver: { type: string, nullable: true, example: "automatic" } + date: { type: string, format: date-time, nullable: true, example: "2023-12-27T09:10:30.336Z" } + originalStart: { type: string, format: date-time, nullable: true, example: "2023-12-07T09:10:30.336Z" } + originalEnd: { type: string, format: date-time, nullable: true, example: "2023-12-08T09:10:30.336Z" } + additionalProperties: true + + WorkfeedShift: + type: object + properties: + id: { type: string, example: "Trcu7MKFomu5y5zv1B8G" } + start: { type: string, format: date-time, example: "2023-10-23T08:00:00.000Z" } + end: { type: string, format: date-time, example: "2023-10-23T16:00:00.000Z" } + employeeID: { type: string, nullable: true } + roleID: { type: string, nullable: true, example: "hLbEKIPTlMh3ehotXl0w" } + departmentID: { type: string, nullable: true, example: "PKHaOSgFA4uguOqmLfWv" } + released: { type: boolean, example: false } + isForSale: { type: boolean, nullable: true, example: false } + comment: + allOf: + - $ref: '#/components/schemas/WorkfeedShiftComment' + nullable: true + customBreak: + allOf: + - $ref: '#/components/schemas/WorkfeedShiftCustomBreak' + nullable: true + overlappingLeaveID: { type: string, nullable: true } + approval: + allOf: + - $ref: '#/components/schemas/WorkfeedShiftApproval' + nullable: true + grossPay: { type: number, nullable: true } + tagIDs: + type: array + items: { type: string } + createTime: { type: string, format: date-time, example: "2023-10-27T09:10:30.336Z" } + updateTime: { type: string, format: date-time, example: "2023-10-27T09:10:30.422Z" } + additionalProperties: true + + WorkfeedEmployeeListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkfeedEmployee' + required: [data] + + WorkfeedEmployeeSingleResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/WorkfeedEmployee' + required: [data] + + WorkfeedShiftListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkfeedShift' + required: [data] + + WorkfeedShiftSingleResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/WorkfeedShift' + required: [data] + + WorkfeedDepartmentListResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/WorkfeedDepartment' + required: [data] + + DepartmentWeatherTimelineResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + description: Hourly contiguous slots from start of range (`00:00`) to end of range (`23:00`, inclusive). Defaults to yesterday+today (48 entries) when date_from/date_to are not provided. + items: + $ref: '#/components/schemas/DepartmentWeatherTimelineEntry' + required: [data] + + DepartmentWeatherTargetsResponse: + allOf: + - $ref: '#/components/schemas/ModuleConfigEnvelopeBase' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/DepartmentWeatherTarget' + required: [data] + + ModuleConfigEntry: + type: object + properties: + module: + type: string + description: Module name + variable: + type: string + description: Configuration variable key + type: + type: string + description: Stored value type in module_config + value: + $ref: '#/components/schemas/ModuleConfigValue' + required: + - module + - variable + - type + - value + + ModuleConfigListResponse: + type: object + properties: + success: + type: boolean + data: + type: array + items: + $ref: '#/components/schemas/ModuleConfigEntry' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + ModuleConfigUpdateResponse: + type: object + properties: + success: + type: boolean + data: + type: boolean + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + ModuleConfigTestResponse: + type: object + properties: + success: + type: boolean + data: + type: string + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + User: + type: object + properties: + id: + type: integer + description: User ID + customer_number: + type: integer + description: e-conomic customer number + display_name: + type: string + description: User's display name + group_id: + type: integer + description: User group/role ID + phone_country_code: + type: integer + description: Phone country code + phone: + type: integer + description: Phone number + email: + type: string + format: email + description: Email address + sms_notifications_enabled: + type: boolean + description: SMS notifications enabled + email_notifications_enabled: + type: boolean + description: Email notifications enabled + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + SubuserGrant: + type: object + properties: + id: + type: integer + billing_customer_number: + type: integer + description: e-conomic customer number + subuser: + type: integer + description: Subuser ID + enabled: + type: boolean + note: + type: string + nullable: true + permissions: + type: array + description: List of permission node keys + items: + type: string + example: BOOKINGS_LIST + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + deleted_at: + type: string + format: date-time + nullable: true + + SubuserGrantCreateRequest: + type: object + required: + - customer_number + - subuser_id + properties: + customer_number: + type: integer + description: e-conomic customer number + subuser_id: + type: integer + description: Subuser ID to grant permissions for + enabled: + type: boolean + default: true + note: + type: string + nullable: true + maxLength: 65535 + permissions: + type: array + description: Optional list of permission keys; defaults will be applied if omitted + items: + type: string + + SubuserGrantUpdateRequest: + type: object + properties: + enabled: + type: boolean + note: + type: string + nullable: true + maxLength: 65535 + permissions: + type: array + items: + type: string + + SubuserGrantSummary: + type: object + description: Summary of a subuser grant grouped by billing customer number + properties: + billing_customer_number: + type: integer + description: e-conomic customer number this grant applies to + permissions: + type: array + description: List of permission node keys enabled for this customer + items: + type: string + + SubuserSelf: + type: object + description: Authenticated subuser profile with enabled grants + properties: + id: + type: integer + username: + type: string + name: + type: string + nullable: true + email: + type: string + format: email + nullable: true + phone_country_code: + type: integer + nullable: true + phone: + type: integer + nullable: true + grants: + type: array + description: Enabled, non-deleted grants for the subuser grouped by billing customer number + items: + $ref: '#/components/schemas/SubuserGrantSummary' + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + suspended_at: + type: string + format: date-time + nullable: true + two_factor_enabled: + type: boolean + description: Indicates if 2FA is enabled for this account + + PermissionNode: + type: object + properties: + key: + type: string + description: Permission node key + name: + type: string + description: + type: string + type: + type: string + description: Permission type (e.g., TOGGLE) + default: + type: boolean + + PermissionNodeGroup: + type: object + properties: + group: + type: string + description: + type: string + nodes: + type: array + items: + $ref: '#/components/schemas/PermissionNode' + + UserCreate: + type: object + required: + - customer_number + - password + properties: + customer_number: + type: integer + password: + type: string + format: password + display_name: + type: string + group_id: + type: integer + email: + type: string + format: email + phone: + type: integer + phone_country_code: + type: integer + + UserUpdate: + type: object + properties: + id: + type: integer + customer_number: + type: integer + display_name: + type: string + group_id: + type: integer + email: + type: string + format: email + phone: + type: integer + phone_country_code: + type: integer + + Order: + type: object + properties: + id: + type: integer + description: Order ID + customer_id: + type: integer + description: Customer number + customer_name: + type: string + description: Customer name + user_id: + type: integer + description: User ID + cashier_id: + type: integer + description: Cashier user ID + + cashier_name: + type: string + description: Cashier name + department_id: + type: integer + description: Department ID + status: + type: string + description: Order status + total_net_amount: + type: number + format: float + description: Total order amount + po: + type: string + description: Purchase order number + nullable: true + lane: + type: string + description: Lane information + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + EconomicTransferQueueStatus: + type: string + enum: + - QUEUED + - PROCESSING + - COMPLETED + - FAILED + + EconomicTransferQueueJob: + type: object + properties: + id: + type: integer + transfer_type: + type: string + enum: + - ORDER_DRAFT_EXPORT + - ORDER_INVOICE_EXPORT + - COLLECTED_INVOICE_EXPORT + status: + $ref: '#/components/schemas/EconomicTransferQueueStatus' + progress_percent: + type: integer + minimum: 0 + maximum: 100 + progress_message: + type: string + nullable: true + attempts: + type: integer + minimum: 0 + max_attempts: + type: integer + minimum: 1 + error_message: + type: string + nullable: true + payload: + type: object + nullable: true + additionalProperties: true + result: + type: object + nullable: true + additionalProperties: true + created_by: + type: integer + nullable: true + created_at: + type: string + format: date-time + nullable: true + updated_at: + type: string + format: date-time + nullable: true + started_at: + type: string + format: date-time + nullable: true + completed_at: + type: string + format: date-time + nullable: true + next_retry_at: + type: string + format: date-time + nullable: true + required: + - id + - transfer_type + - status + - progress_percent + - attempts + - max_attempts + + EconomicTransferQueueEnqueueResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job_id: + type: integer + minimum: 1 + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job_id + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueStatusResponse: + type: object + properties: + success: + type: boolean + data: + $ref: '#/components/schemas/EconomicTransferQueueJob' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueRetryResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + message: + type: string + job: + $ref: '#/components/schemas/EconomicTransferQueueJob' + required: + - message + - job + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + EconomicTransferQueueListResponse: + type: object + properties: + success: + type: boolean + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/EconomicTransferQueueJob' + count: + type: integer + minimum: 0 + required: + - items + - count + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + CollectedInvoiceEconomicCompareResponse: + type: object + description: Result of comparing a collected invoice with its E-conomic counterpart + properties: + collected_invoice_id: + type: integer + description: The internal collected invoice ID + example: 123 + draft_id: + type: integer + nullable: true + description: E-conomic draft invoice ID, if present + example: 456 + booked_id: + type: integer + nullable: true + description: E-conomic booked invoice ID, if present + example: 28368 + warnings: + type: array + description: List of warnings detected during comparison + items: + type: string + example: + - "Total amount mismatch for draft invoice ID 456: E-Conomic total is 867.5, internal total is 694" + draft_total: + type: number + format: float + nullable: true + description: Total amount from the E-conomic draft (gross) + example: 867.5 + booked_total: + type: number + format: float + nullable: true + description: Total amount from the E-conomic booked invoice (gross minus VAT if applicable) + example: 694 + difference: + type: number + format: float + nullable: true + description: Selected e-conomic total (draft when available, otherwise booked) minus internal_total + example: 0 + internal_total: + type: number + format: float + description: Internal total amount for the collected invoice + example: 694 + required: + - collected_invoice_id + - internal_total + + CollectedInvoiceEconomicV2DetailsResponse: + type: object + properties: + collected_invoice_id: + type: integer + external_id: + type: string + order_ids: + type: array + items: + type: integer + economic: + type: object + properties: + draft_id: + type: integer + nullable: true + booked_id: + type: integer + nullable: true + customer: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary' + internal: + type: object + required: [normalized] + properties: + normalized: + $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + draft: + type: object + properties: + exists: + type: boolean + raw: + type: object + nullable: true + additionalProperties: true + normalized: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + nullable: true + booked: + type: object + properties: + exists: + type: boolean + raw: + type: object + nullable: true + additionalProperties: true + normalized: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedInvoice' + nullable: true + warnings: + type: array + items: + type: string + required: + - collected_invoice_id + - order_ids + - economic + - customer + - internal + - draft + - booked + - warnings + + CollectedInvoiceEconomicV2CustomerSummary: + type: object + properties: + internal_customer_number: + type: integer + nullable: true + draft_customer_number: + type: integer + nullable: true + booked_customer_number: + type: integer + nullable: true + exists: + type: boolean + name: + type: string + nullable: true + barred: + type: boolean + nullable: true + required: + - exists + + CollectedInvoiceEconomicV2CompareResponse: + type: object + properties: + collected_invoice_id: + type: integer + details: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2DetailsResponse' + comparison: + $ref: '#/components/schemas/EconomicV2Comparison' + warnings: + type: array + items: + type: string + required: + - collected_invoice_id + - details + - comparison + - warnings + + CollectedInvoiceEconomicV2CompareBulkResponse: + type: object + properties: + requested: + type: integer + compared: + type: integer + failed: + type: integer + results: + type: array + items: + $ref: '#/components/schemas/CollectedInvoiceEconomicV2CompareResponse' + errors: + type: array + items: + type: object + properties: + collected_invoice_id: + type: integer + error: + type: string + required: + - requested + - compared + - failed + - results + - errors + + CollectedInvoiceEconomicV2RevenueStatisticsResponse: + type: object + properties: + filters: + type: object + properties: + dateFrom: + type: string + format: date + dateTo: + type: string + format: date + customer_numbers: + type: array + items: + type: integer + department_numbers: + type: array + items: + type: integer + currency: + type: string + nullable: true + barred: + type: string + enum: [all, barred, active] + max_pages: + type: integer + summary: + $ref: '#/components/schemas/EconomicV2RevenueSummary' + customers: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueCustomerStat' + departments: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueDepartmentStat' + currencies: + type: array + items: + $ref: '#/components/schemas/EconomicV2RevenueCurrencyStat' + warnings: + type: array + items: + type: string + required: + - filters + - summary + - customers + - departments + - currencies + - warnings + + EconomicV2RevenueSummary: + type: object + properties: + invoice_count: + type: integer + line_count: + type: integer + unique_customers: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + average_invoice_net_amount: + type: number + required: + - invoice_count + - line_count + - unique_customers + - net_amount + - vat_amount + - gross_amount + - average_invoice_net_amount + + EconomicV2RevenueCustomerStat: + type: object + properties: + customer_number: + type: integer + customer_name: + type: string + nullable: true + barred: + type: boolean + nullable: true + invoice_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - customer_number + - invoice_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2RevenueDepartmentStat: + type: object + properties: + department_key: + type: string + department_number: + type: integer + nullable: true + invoice_count: + type: integer + line_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - department_key + - invoice_count + - line_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2RevenueCurrencyStat: + type: object + properties: + currency: + type: string + invoice_count: + type: integer + net_amount: + type: number + vat_amount: + type: number + gross_amount: + type: number + required: + - currency + - invoice_count + - net_amount + - vat_amount + - gross_amount + + EconomicV2NormalizedInvoice: + type: object + properties: + source: + type: string + enum: [internal, draft, booked] + totals: + type: object + properties: + net_total: + type: number + line_net_total: + type: number + line_count: + type: integer + billable_line_count: + type: integer + difference_from_line_sum: + type: number + nullable: true + departments: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + lines: + type: array + items: + $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + warnings: + type: array + items: + type: string + required: + - source + - totals + - departments + - lines + - warnings + + EconomicV2NormalizedLineItem: + type: object + properties: + index: + type: integer + source: + type: string + source_order_id: + type: integer + nullable: true + source_line_id: + type: integer + nullable: true + line_type: + type: string + enum: [product, discount, text] + billable: + type: boolean + product_number: + type: string + nullable: true + product_id: + type: integer + nullable: true + description: + type: string + reference: + type: string + quantity: + type: number + unit_net_price: + type: number + line_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + match_key: + type: string + required: + - source + - line_type + - billable + - description + - reference + - quantity + - unit_net_price + - line_net_amount + - department_distribution + - match_key + + EconomicV2DepartmentDistribution: + type: object + additionalProperties: + type: number + example: + "75": 100 + + EconomicV2Comparison: + type: object + properties: + totals: + type: object + properties: + internal_net_total: + type: number + targets: + type: object + properties: + draft: + $ref: '#/components/schemas/EconomicV2TargetComparison' + booked: + $ref: '#/components/schemas/EconomicV2TargetComparison' + warnings: + type: array + items: + type: string + required: + - totals + - targets + - warnings + + EconomicV2TargetComparison: + type: object + properties: + target: + type: string + enum: [draft, booked] + status: + type: string + enum: [exact_match, partial_mismatch, total_mismatch, missing_target] + overall_match: + type: boolean + totals: + $ref: '#/components/schemas/EconomicV2TotalsComparison' + lines: + type: object + properties: + summary: + type: object + properties: + internal_billable_count: + type: integer + target_billable_count: + type: integer + mismatch_count: + type: integer + diff: + type: array + items: + $ref: '#/components/schemas/EconomicV2LineDiffEntry' + departments: + type: object + properties: + matches: + type: boolean + diff: + type: array + items: + $ref: '#/components/schemas/EconomicV2DepartmentDiffEntry' + mismatch_reasons: + type: array + items: + type: string + warnings: + type: array + items: + type: string + required: + - target + - status + - overall_match + - totals + - lines + - departments + - mismatch_reasons + - warnings + + EconomicV2TotalsComparison: + type: object + properties: + internal_net_total: + type: number + nullable: true + target_net_total: + type: number + nullable: true + difference: + type: number + nullable: true + abs_difference: + type: number + nullable: true + matches: + type: boolean + required: + - matches + + EconomicV2LineDiffEntry: + type: object + properties: + match_key: + type: string + reasons: + type: array + items: + type: string + internal_line: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + nullable: true + target_line: + allOf: + - $ref: '#/components/schemas/EconomicV2NormalizedLineItem' + nullable: true + required: + - match_key + - reasons + + EconomicV2DepartmentDiffEntry: + type: object + properties: + department_key: + type: string + internal_amount: + type: number + target_amount: + type: number + difference: + type: number + matches: + type: boolean + required: + - department_key + - internal_amount + - target_amount + - difference + - matches + + InvoicingDistributionV2Transaction: + type: object + properties: + id: + type: integer + date: + type: string + format: date-time + amount: + type: number + booked: + type: boolean + department_id: + type: integer + excluded: + type: boolean + required: [id, date, amount, booked, department_id, excluded] + + InvoicingDistributionV2Customer: + type: object + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2Transaction' + requires_action: + type: boolean + meta: + type: object + additionalProperties: true + required: [customer_number, customer_name, transactions, requires_action, meta] + + InvoicingDistributionV2CategoryResponse: + type: object + properties: + customers: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2Customer' + collective_results: + type: object + additionalProperties: true + warnings: + type: array + items: + type: string + required: [customers, collective_results, warnings] + + InvoicingDistributionV2FixedPricingResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2WashSubscriptionsResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2CustomerPricesResponse: + $ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse' + + InvoicingDistributionV2BookedDepartment75Group: + type: object + properties: + month: + type: string + example: '2026-01' + source_category: + type: string + enum: [fixed_pricing, wash_subscriptions, unclassified] + invoice_ids: + type: array + items: + type: integer + booked_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + undistributed_net_amount: + type: number + required: + - month + - source_category + - invoice_ids + - booked_net_amount + - department_distribution + - undistributed_net_amount + + InvoicingDistributionV2BookedDepartment75Meta: + type: object + properties: + booked_net_amount: + type: number + distributed_net_amount: + type: number + undistributed_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + booked_groups: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Group' + required: + - booked_net_amount + - distributed_net_amount + - undistributed_net_amount + - department_distribution + - booked_groups + + InvoicingDistributionV2BookedDepartment75Customer: + allOf: + - $ref: '#/components/schemas/InvoicingDistributionV2Customer' + - type: object + properties: + meta: + type: object + properties: + booked_department_75: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Meta' + required: + - booked_department_75 + + InvoicingDistributionV2BookedDepartment75CollectiveResults: + type: object + properties: + booked_net_amount: + type: number + distributed_net_amount: + type: number + undistributed_net_amount: + type: number + department_distribution: + $ref: '#/components/schemas/EconomicV2DepartmentDistribution' + department_distribution_parsed: + type: object + additionalProperties: + type: number + required: + - booked_net_amount + - distributed_net_amount + - undistributed_net_amount + - department_distribution + - department_distribution_parsed + + InvoicingDistributionV2BookedDepartment75Response: + type: object + properties: + customers: + type: array + items: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Customer' + collective_results: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75CollectiveResults' + warnings: + type: array + items: + type: string + required: [customers, collective_results, warnings] + + InvoicingDistributionV2AllResponse: + type: object + properties: + fixed_pricing: + $ref: '#/components/schemas/InvoicingDistributionV2FixedPricingResponse' + wash_subscriptions: + $ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse' + customer_prices: + $ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse' + booked_department_75: + $ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response' + required: [fixed_pricing, wash_subscriptions, customer_prices, booked_department_75] + + PricingHistoryVersionEntry: + type: object + properties: + id: + type: integer + type: + type: string + enum: [fixed_pricing, vehicle_subscription, discount_override] + customer_number: + type: integer + effective_from: + type: string + format: date-time + effective_to: + type: string + format: date-time + nullable: true + source: + type: string + confidence: + type: number + minimum: 0 + maximum: 1 + inferred: + type: boolean + metadata_json: + oneOf: + - type: string + - type: object + additionalProperties: true + - type: array + items: {} + nullable: true + required: + - id + - type + - customer_number + - effective_from + - source + - confidence + - inferred + + CustomerPricingHistoryResponse: + type: object + properties: + customer_number: + type: integer + fixed_pricing: + type: array + items: + type: object + additionalProperties: true + vehicle_subscriptions: + type: array + items: + type: object + additionalProperties: true + discount_overrides: + type: array + items: + type: object + additionalProperties: true + timeline: + type: array + items: + $ref: '#/components/schemas/PricingHistoryVersionEntry' + required: + - customer_number + - fixed_pricing + - vehicle_subscriptions + - discount_overrides + - timeline + + InvoicingWashSubscriptionsDistributionResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + $ref: '#/components/schemas/InvoicingWashSubscriptionsDistributionCustomer' + meta: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionMeta' + includes: + type: object + additionalProperties: true + required: + - success + - data + - meta + - includes + + InvoicingWashSubscriptionsDistributionCustomer: + type: object + properties: + id: + type: integer + nullable: true + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionTransaction' + requires_action: + type: boolean + meta: + type: object + properties: + subscription: + type: object + additionalProperties: true + required: + - subscription + required: + - customer_number + - customer_name + - transactions + - requires_action + - meta + + InvoicingFixedPricingDistributionResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionCustomer' + meta: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionMeta' + includes: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionIncludes' + required: + - success + - data + - meta + - includes + + InvoicingFixedPricingDistributionCustomer: + type: object + properties: + id: + type: integer + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionTransaction' + requires_action: + type: boolean + meta: + type: object + properties: + fixed_pricing: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionFixedPricing' + required: + - fixed_pricing + required: + - id + - customer_number + - customer_name + - transactions + - requires_action + - meta + + InvoicingFixedPricingDistributionTransaction: + type: object + properties: + id: + type: integer + date: + type: string + description: Datetime in `YYYY-MM-DD HH:mm:ss` format. + example: '2026-02-02 10:43:41' + amount: + type: number + booked: + type: boolean + excluded: + type: boolean + required: + - id + - date + - amount + - booked + - excluded + + InvoicingFixedPricingDistributionFixedPricing: + type: object + properties: + customer_number: + type: integer + price: + type: number + description: + type: string + original_price: + type: number + department_totals: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + department_totals_relative: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + required: + - customer_number + - price + - description + - original_price + - department_totals + - department_totals_relative + + InvoicingFixedPricingDistributionMeta: + type: object + properties: + date_from: + type: string + description: Datetime in `YYYY-MM-DD HH:mm:ss` format. + example: '2026-02-01 00:00:00' + date_to: + type: string + description: Datetime in `YYYY-MM-DD HH:mm:ss` format. + example: '2026-02-28 23:59:59' + required: + - date_from + - date_to + + InvoicingFixedPricingDistributionIncludes: + type: object + properties: + debug_invoicing_period_customers_with_orders_in_date_range: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_process_customer_numbers: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_get_transactions_for_customers_in_date_range: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_calculate_transaction_totals: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + debug_invoicing_period_construct_customer_objects: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionExecutionTime' + collective_fixed_pricing_results: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionCollectiveResults' + additionalProperties: true + + InvoicingFixedPricingDistributionExecutionTime: + type: object + properties: + execution_time: + type: number + required: + - execution_time + + InvoicingFixedPricingDistributionCollectiveResults: + type: object + properties: + total_fixed_price: + type: number + total_original_price: + type: number + total_department_totals: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + total_department_totals_relative: + $ref: '#/components/schemas/InvoicingFixedPricingDistributionNumberMapOrEmptyArray' + total_department_totals_parsed: + type: object + additionalProperties: + type: number + total_department_totals_relative_parsed: + type: object + additionalProperties: + type: number + required: + - total_fixed_price + - total_original_price + - total_department_totals + - total_department_totals_relative + - total_department_totals_parsed + - total_department_totals_relative_parsed + + InvoicingFixedPricingDistributionNumberMapOrEmptyArray: + oneOf: + - type: object + additionalProperties: + type: number + - type: array + maxItems: 0 + + SelfServeLaneStatus: + type: object + properties: + id: + type: integer + description: Lane ID + status: + type: string + description: Current lane status (e.g., IDLE, OCCUPIED) + mode: + type: string + description: Current lane mode (e.g., AUTOMATIC, MANUAL) + state: + type: string + description: Current lane state (e.g., READY, WASHING) + wash_start_time: + type: integer + description: Timestamp when the wash started (0 if not washing) + nullable: true + elapsed_wash_time: + type: integer + description: Elapsed wash time in seconds + nullable: true + license_plate: + type: string + description: License plate of the vehicle in the lane + nullable: true + customer_number: + type: integer + description: Customer number associated with the current lane use + nullable: true + + SelfServeLaneMachineRelayStatus: + type: object + properties: + lane_id: + type: integer + relay: + type: string + enum: [MACHINE, MACHINE_PROGRAM_PICKER, MACHINE_CLEANER] + relay_id: + type: string + online: + type: boolean + on: + type: boolean + + SelfServeConfig: + type: object + properties: + enabled: + type: boolean + description: Whether the self-serve module is enabled + minute_product: + type: integer + description: The product ID used for minute-based billing + machine_wash_minutes_included: + type: integer + description: Included machine wash minutes before minute-based billing starts + + SelfserveLaneService: + type: string + description: Allowed self-serve lane service name + enum: + - MACHINE + + SelfserveMachineType: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + nullable: true + + SelfserveVisibleQuestion: + type: object + properties: + id: + type: integer + question: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + answer: + type: boolean + nullable: true + + SelfserveTaskDecision: + type: object + properties: + id: + type: integer + task: + type: string + description: + type: string + condition_id: + type: integer + nullable: true + order_priority: + type: integer + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: + type: integer + + SelfserveWashSession: + type: object + properties: + id: + type: integer + lane_id: + type: integer + department_id: + type: integer + machine_type_id: + type: integer + nullable: true + customer_number: + type: integer + nullable: true + vehicle_id: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + reg: + type: string + status: + type: string + allowed: + type: boolean + machine_relay_enabled: + type: boolean + machine_relay_enabled_at: + type: string + format: date-time + nullable: true + machine_start_triggered: + type: boolean + machine_start_triggered_at: + type: string + format: date-time + nullable: true + wash_started_at: + type: string + format: date-time + nullable: true + order_id: + type: integer + nullable: true + completed_at: + type: string + format: date-time + nullable: true + metadata: + type: object + additionalProperties: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + nullable: true + + SelfserveWashQuestionAnswer: + type: object + properties: + question_id: + type: integer + question: + type: string + answer: + type: boolean + nullable: true + answered_at: + type: string + format: date-time + nullable: true + + SelfserveWashTaskSnapshot: + type: object + properties: + task_id: + type: integer + nullable: true + task: + type: string + description: + type: string + nullable: true + services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + buttons: + type: array + items: + type: integer + + SelfserveWashEvent: + type: object + properties: + id: + type: integer + type: + type: string + payload: + type: object + additionalProperties: true + nullable: true + created_at: + type: string + format: date-time + + SelfserveVehicleAllowedResponse: + type: object + properties: + lane: + $ref: '#/components/schemas/DepartmentLane' + machine_type: + allOf: + - $ref: '#/components/schemas/SelfserveMachineType' + nullable: true + vehicle: + type: object + additionalProperties: true + nullable: true + reg: + type: string + customer_number: + type: integer + nullable: true + vehicle_type_id: + type: integer + nullable: true + questions: + type: array + items: + $ref: '#/components/schemas/SelfserveVisibleQuestion' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveTaskDecision' + allowed_services: + type: array + items: + $ref: '#/components/schemas/SelfserveLaneService' + machine_available: + type: boolean + all_visible_questions_answered: + type: boolean + allowed: + type: boolean + session: + allOf: + - $ref: '#/components/schemas/SelfserveWashSession' + nullable: true + + SelfserveWashSummary: + type: object + properties: + session: + $ref: '#/components/schemas/SelfserveWashSession' + lane: + allOf: + - $ref: '#/components/schemas/DepartmentLane' + nullable: true + machine_type: + allOf: + - $ref: '#/components/schemas/SelfserveMachineType' + nullable: true + questions: + type: array + items: + $ref: '#/components/schemas/SelfserveWashQuestionAnswer' + tasks: + type: array + items: + $ref: '#/components/schemas/SelfserveWashTaskSnapshot' + events: + type: array + items: + $ref: '#/components/schemas/SelfserveWashEvent' + + DepartmentSelfserveVehicleConditionMutationResponse: + type: object + properties: + condition: + $ref: '#/components/schemas/DepartmentSelfserveVehicleCondition' + selfserve: + $ref: '#/components/schemas/SelfserveWashSummary' + + MachineButtonPressWebhookResponse: + type: object + properties: + message: + type: string + scanner: + type: string + lane_id: + type: integer + selfserve: + $ref: '#/components/schemas/SelfserveWashSummary' + + DepartmentSelfserveQuestion: + type: object + properties: + id: + type: integer + description: Question ID + department: + type: integer + description: Department ID + lane: + type: integer + description: Lane ID + product: + type: integer + description: Product ID + condition_id: + type: integer + description: Question condition object ID + nullable: true + question: + type: string + description: Question text + description: + type: string + description: Question description + order_priority: + type: integer + description: Display order priority (lower numbers shown first) + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentSelfserveTask: + type: object + properties: + id: + type: integer + description: Task ID + department: + type: integer + description: Department ID + lane: + type: integer + description: Lane ID + product: + type: integer + description: Product ID + machine_type_id: + type: integer + description: Reusable machine type ID + nullable: true + condition_id: + type: integer + description: Condition ID (if conditional task) + nullable: true + task: + type: string + description: Task text + description: + type: string + description: Task description + order_priority: + type: integer + description: Display order priority (lower numbers shown first) + services: + type: array + description: Services enabled by this task. Each item must be a valid service enum name. + items: + $ref: '#/components/schemas/SelfserveLaneService' + default: [] + buttons: + type: array + description: Dynamic image button IDs enabled by this task. + items: + type: integer + default: [] + dynamic_images_vehicle_type: + type: integer + nullable: true + description: Optional vehicle type selection override for the machine UI. + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentSelfserveCondition: + type: object + properties: + id: + type: integer + description: Condition ID + department: + type: integer + description: Department ID + lane: + type: integer + description: Lane ID + product: + type: integer + description: Product ID + machine_type_id: + type: integer + description: Reusable machine type ID + nullable: true + condition_id: + type: integer + description: Optional condition ID + nullable: true + name: + type: string + description: Condition name + description: + type: string + description: Condition description + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentSelfserveConditionRule: + type: object + properties: + id: + type: integer + description: Rule ID + condition_id: + type: integer + description: Condition object ID + type: + type: string + description: Condition type (e.g., IS_TRUE, IS_FALSE) + object_type: + type: string + description: The object type to which the condition applies (e.g., question, task, etc.) + object_id: + type: integer + description: The object id to which the condition applies + name: + type: string + description: Condition name + description: + type: string + description: Condition description + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentSelfserveVehicleCondition: + type: object + properties: + id: + type: integer + description: Vehicle condition ID + department: + type: integer + description: Department ID + lane: + type: integer + description: Lane ID + customer_id: + type: integer + description: Customer ID + nullable: true + reg: + type: string + description: Vehicle registration number + question: + type: integer + description: Question ID + value: + type: boolean + description: Answer value + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + deleted_at: + type: string + format: date-time + nullable: true + + OrderCreate: + type: object + required: + - customer_id + - department_id + properties: + customer_id: + type: integer + department_id: + type: integer + cashier_id: + type: integer + po: + type: string + lane: + type: string + + OrderUpdate: + type: object + properties: + id: + type: integer + customer_id: + type: integer + department_id: + type: integer + status: + type: string + po: + type: string + lane: + type: string + + OrderItem: + type: object + properties: + id: + type: integer + order_id: + type: integer + product_id: + type: integer + product_name: + type: string + quantity: + type: integer + unit_price: + type: number + format: float + discount: + type: number + format: float + total_price: + type: number + format: float + + OrderItemCreate: + type: object + required: + - order_id + - product_id + - quantity + properties: + order_id: + type: integer + product_id: + type: integer + quantity: + type: integer + discount: + type: number + format: float + + OrderItemUpdate: + type: object + required: + - id + properties: + id: + type: integer + quantity: + type: integer + discount: + type: number + format: float + + Department: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + economic_department_id: + type: integer + visible: + type: boolean + dimension: + type: integer + branding: + type: integer + longitude: + type: number + format: float + latitude: + type: number + format: float + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentGuest: + type: object + properties: + id: + type: integer + name: + type: string + longitude: + type: number + format: float + latitude: + type: number + format: float + address: + type: string + description: Department address (same as description) + self_serve_enabled: + type: boolean + lanes: + type: array + items: + $ref: '#/components/schemas/DepartmentLaneGuest' + + DepartmentLaneGuest: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + products: + type: array + items: + type: integer + machine_available: + type: boolean + + DepartmentCreate: + type: object + required: + - name + - economic_department_id + properties: + name: + type: string + description: + type: string + economic_department_id: + type: integer + visible: + type: boolean + longitude: + type: number + format: float + latitude: + type: number + format: float + + DepartmentUpdate: + type: object + required: + - id + properties: + id: + type: integer + name: + type: string + description: + type: string + visible: + type: boolean + longitude: + type: number + format: float + latitude: + type: number + format: float + + DepartmentLane: + type: object + properties: + id: + type: integer + department: + type: integer + name: + type: string + relay_in_id: + type: string + relay_out_id: + type: string + relay_machine_id: + type: string + relay_machine_program_picker_id: + type: string + relay_machine_cleaner_id: + type: string + dynamic_image_id: + type: integer + nullable: true + minimum: 1 + machine_type_id: + type: integer + nullable: true + status: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentLaneCreate: + type: object + required: + - department + - name + properties: + department: + type: integer + name: + type: string + relay_in_id: + type: string + relay_out_id: + type: string + relay_machine_id: + type: string + relay_machine_program_picker_id: + type: string + relay_machine_cleaner_id: + type: string + dynamic_image_id: + type: integer + nullable: true + minimum: 1 + machine_type_id: + type: integer + nullable: true + + DepartmentLaneUpdate: + type: object + required: + - id + properties: + id: + type: integer + department: + type: integer + name: + type: string + relay_in_id: + type: string + relay_out_id: + type: string + relay_machine_id: + type: string + relay_machine_program_picker_id: + type: string + relay_machine_cleaner_id: + type: string + dynamic_image_id: + type: integer + nullable: true + minimum: 1 + machine_type_id: + type: integer + nullable: true + + DepartmentGate: + type: object + properties: + id: + type: integer + department: + type: integer + is_entrance: + type: boolean + is_exit: + type: boolean + name: + type: string + config: + $ref: '#/components/schemas/DepartmentGateConfig' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentGateConfig: + type: object + required: + - type + properties: + type: + type: string + example: PHONE_CALL + phone_number: + type: string + nullable: true + example: +4512345678 + call_duration_threshold: + type: integer + nullable: true + example: 10 + description: | + Configuration for the department gate. + If type is 'PHONE_CALL', 'phone_number' and 'call_duration_threshold' are required. + + DepartmentGateCreate: + type: object + required: + - department + - is_entrance + - is_exit + - name + - config + properties: + department: + type: integer + is_entrance: + type: boolean + is_exit: + type: boolean + name: + type: string + config: + $ref: '#/components/schemas/DepartmentGateConfig' + + DepartmentGateUpdate: + type: object + required: + - id + properties: + id: + type: integer + is_entrance: + type: boolean + is_exit: + type: boolean + name: + type: string + config: + $ref: '#/components/schemas/DepartmentGateConfig' + + DepartmentRelay: + type: object + properties: + id: + type: integer + department: + type: integer + relay_id: + type: string + name: + type: string + type: + type: string + enum: [SWITCH, TRIGGER] + config: + $ref: '#/components/schemas/DepartmentRelayConfig' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + DepartmentRelayConfig: + type: object + properties: + what_happens: + type: string + nullable: true + example: open_gate + webhook_token: + type: string + nullable: true + example: secret_token + description: | + Configuration for the department relay. + If the relay 'type' is 'TRIGGER', 'what_happens' and 'webhook_token' are required. + + DepartmentRelayCreate: + type: object + required: + - department + - relay_id + - name + - type + - config + properties: + department: + type: integer + relay_id: + type: string + name: + type: string + type: + type: string + enum: [SWITCH, TRIGGER] + config: + $ref: '#/components/schemas/DepartmentRelayConfig' + + DepartmentRelayUpdate: + type: object + required: + - id + properties: + id: + type: integer + relay_id: + type: string + name: + type: string + type: + type: string + enum: [SWITCH, TRIGGER] + config: + $ref: '#/components/schemas/DepartmentRelayConfig' + + Product: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + price: + type: number + format: float + category_id: + type: integer + category_name: + type: string + visible: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + ProductCreate: + type: object + required: + - name + - price + - category_id + properties: + name: + type: string + description: + type: string + price: + type: number + format: float + category_id: + type: integer + visible: + type: boolean + + ProductUpdate: + type: object + required: + - id + properties: + id: + type: integer + name: + type: string + description: + type: string + price: + type: number + format: float + category_id: + type: integer + visible: + type: boolean + + Category: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + CategoryCreate: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + + CategoryUpdate: + type: object + required: + - id + properties: + id: + type: integer + name: + type: string + description: + type: string + + ModuleActionLog: + type: object + properties: + id: + type: integer + description: Log ID + module: + type: string + description: The module name + action: + type: string + description: The action name + status_code: + type: integer + description: HTTP status code + data: + type: object + description: The action data (JSON decoded) + created_at: + type: string + format: date-time + description: Log creation timestamp + + Booking: + type: object + properties: + id: + type: integer + customer_id: + type: integer + department_id: + type: integer + booking_time: + type: string + format: date-time + status: + type: string + created_at: + type: string + format: date-time + + BookingUpdate: + type: object + required: + - id + properties: + id: + type: integer + status: + type: string + booking_time: + type: string + format: date-time + + Vehicle: + type: object + properties: + id: + type: integer + user_id: + type: integer + description: Internal user ID owning the customer account + reg: + type: string + description: Vehicle registration number + customer_id: + type: integer + customer_name: + type: string + type: + type: integer + description: Product ID representing the vehicle wash type + reference: + type: string + nullable: true + description: Optional external reference/label + wash_subscription: + type: boolean + barred: + type: boolean + description: True if the associated customer is barred + addons: + type: object + properties: + enabled: { type: integer } + available: { type: integer } + list: + type: array + items: + type: object + last_order_id: + type: integer + nullable: true + xlvask: + type: object + nullable: true + description: XL Vask vehicle data when available + vehicle_types: + type: array + items: + type: object + created_at: + type: string + format: date-time + + Notification: + type: object + properties: + id: + type: integer + user_id: + type: integer + title: + type: string + message: + type: string + read: + type: boolean + created_at: + type: string + format: date-time + + NotificationCreate: + type: object + required: + - user_id + - title + - message + properties: + user_id: + type: integer + title: + type: string + message: + type: string + + Permission: + type: object + properties: + name: + type: string + description: Permission identifier + description: + type: string + description: Human-readable description + + GoalsCriteria: + type: object + description: Goal evaluation criteria + properties: + type: + type: string + description: Criteria type + enum: [PRODUCT, REVENUE, VISITS, NONE] + example: PRODUCT + target: + type: number + description: Target value for the goal + example: 100 + target_duration: + type: string + nullable: true + description: | + Optional advanced target duration mode. + Accepted values: ENTIRE_DURATION, WEEKS, MONTHS, YEARS. + When omitted, legacy target behavior is preserved for backward compatibility. + The canonical field name is snake_case `target_duration`. + For backward-compatibility the API also accepts camelCase `targetDuration` on input. + enum: [ENTIRE_DURATION, WEEKS, MONTHS, YEARS] + example: WEEKS + target_duration_every: + type: integer + nullable: true + minimum: 1 + description: | + Optional cadence value used with `target_duration` WEEKS, MONTHS, or YEARS. + Example: with `target_duration=WEEKS` and `target_duration_every=2`, + the target applies every second week. + Ignored when `target_duration=ENTIRE_DURATION`. + The canonical field name is snake_case `target_duration_every`. + For backward-compatibility the API also accepts camelCase `targetDurationEvery` on input. + example: 1 + label: + type: string + description: Optional short label/title for this goal criteria (max 255 characters) + example: Q1 Revenue Goal + start: + type: string + format: date-time + description: Start of the evaluation window (ISO 8601) + end: + type: string + format: date-time + description: End of the evaluation window (ISO 8601) + users: + type: array + description: List of user customer numbers included in the criteria + items: { type: integer } + departments: + type: array + description: List of department IDs included in the criteria + items: { type: integer } + products: + type: array + description: List of product IDs included in the criteria + items: { type: integer } + progress_alert_frequency: + type: string + description: | + Frequency of progress alerts for the goal. + Accepted values: DAILY, WEEKLY, MONTHLY, CHANGED, NONE. + The canonical field name is snake_case `progress_alert_frequency`. + For backward-compatibility the API also accepts camelCase `progressAlertFrequency` on input. + enum: [DAILY, WEEKLY, MONTHLY, CHANGED, NONE] + example: DAILY + progress_alert_destination: + type: string + description: | + Destination/channel where progress alerts should be delivered. + Accepted values: SLACK, EMAIL, SMS, NONE. + The canonical field name is snake_case `progress_alert_destination`. + For backward-compatibility the API also accepts camelCase `progressAlertDestination` on input. + enum: [SLACK, EMAIL, SMS, NONE] + example: NONE + progress_alert_progress_type: + type: string + description: | + What part of the progress should be included in alert messages. + Accepted values: ALL, PERCENTAGE_ONLY, COUNT_ONLY, COUNT_AND_TARGET, NONE. + The canonical field name is snake_case `progress_alert_progress_type`. + For backward-compatibility the API also accepts camelCase `progressAlertProgressType` on input. + enum: [ALL, PERCENTAGE_ONLY, COUNT_ONLY, COUNT_AND_TARGET, NONE] + example: ALL + progress_alert_style: + type: string + description: | + Presentation style of the alert. + Accepted values: DEPARTMENT_COMPARE, COLLECTIVE, SINGLE_DEPARTMENT, NONE. + The canonical field name is snake_case `progress_alert_style`. + For backward-compatibility the API also accepts camelCase `progressAlertStyle` on input. + enum: [DEPARTMENT_COMPARE, COLLECTIVE, SINGLE_DEPARTMENT, NONE] + example: NONE + progress_alert_format: + type: string + nullable: true + description: | + Optional custom template for the alert body. Supports tokens `{label}`, `{percent}`, `{count}`, `{target}`, `{timeframe}`, `{departments}`, `{prefix}`, `{body}`. + Max length depends on destination: 160 characters for SMS; 1024 characters for EMAIL/SLACK/other. + The canonical field name is snake_case `progress_alert_format`. + For backward-compatibility the API also accepts camelCase `progressAlertFormat` on input. + progress_alert_weekdays: + type: array + description: | + Weekdays on which progress alerts should be sent. + Use one or more of: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY. + The canonical field name is snake_case `progress_alert_weekdays`. + For backward-compatibility the API also accepts camelCase `progressAlertWeekdays` on input. + items: + type: string + enum: [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY] + example: [MONDAY, WEDNESDAY, FRIDAY] + progress_alert_time_of_day: + type: string + nullable: true + description: | + Time of day (with timezone) when progress alerts should be sent. + Format: `HH:MMZ` or `HH:MM±HH:MM` (24-hour clock with UTC offset). Examples: `14:30Z`, `09:15+02:00`, `18:45-05:00`. + The canonical field name is snake_case `progress_alert_time_of_day`. + For backward-compatibility the API also accepts camelCase `progressAlertTimeOfDay` on input. + pattern: '^([01]\d|2[0-3]):[0-5]\d(?:Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$' + example: "14:30+02:00" + department_daily_targets: + type: object + description: | + Optional per-department custom daily targets. Keys are department IDs and values are non-negative numbers representing the target per operating day for that department. + If omitted, the daily target is split evenly across selected departments. The canonical field name is snake_case `department_daily_targets`. + For backward-compatibility the API also accepts camelCase `departmentDailyTargets` on input. + x-additionalPropertiesName: department_id + additionalProperties: + type: number + minimum: 0 + example: + "12": 3 + "15": 5 + + GoalProgressDetails: + type: object + properties: + count: + type: number + description: Current progress value + target: + type: number + description: Target value for the period + date_from: + type: string + nullable: true + description: Inclusive period start datetime (ISO-8601), null when no lower bound applies + example: "2026-01-01T00:00:00+00:00" + date_end: + type: string + nullable: true + description: Inclusive period end datetime (ISO-8601), null when no upper bound applies + example: "2026-02-26T23:59:59+00:00" + + DepartmentGoalProgress: + type: object + title: Department progress details + description: | + Goal progress details for a single department across multiple timeframes. + Timeframes are clamped to the goal timeframe (never before goal start and never after goal end). + properties: + all: + $ref: '#/components/schemas/GoalProgressDetails' + today: + $ref: '#/components/schemas/GoalProgressDetails' + week: + $ref: '#/components/schemas/GoalProgressDetails' + month: + $ref: '#/components/schemas/GoalProgressDetails' + year: + $ref: '#/components/schemas/GoalProgressDetails' + to_date: + $ref: '#/components/schemas/GoalProgressDetails' + + DepartmentGoal: + type: object + properties: + id: + type: integer + created_by: + type: integer + description: ID of the user who created the goal + departments: + type: array + items: { type: integer } + criteria: + $ref: '#/components/schemas/GoalsCriteria' + progress: + type: object + description: | + Goal progress details for various timeframes. + `year` starts at January 1 of the current year or the goal start, whichever is later. + `to_date` starts at the goal start and ends at today (also clamped by goal end). + properties: + all: + $ref: '#/components/schemas/GoalProgressDetails' + today: + $ref: '#/components/schemas/GoalProgressDetails' + week: + $ref: '#/components/schemas/GoalProgressDetails' + month: + $ref: '#/components/schemas/GoalProgressDetails' + year: + $ref: '#/components/schemas/GoalProgressDetails' + to_date: + $ref: '#/components/schemas/GoalProgressDetails' + departmental_distribution: + type: object + description: | + Progress details broken down by department. Keys are department IDs. + Includes `all`, `today`, `week`, `month`, `year`, and `to_date` timeframes. + x-additionalPropertiesName: department_id + additionalProperties: + $ref: '#/components/schemas/DepartmentGoalProgress' + example: + "12": + all: + count: 15 + target: 100 + today: + count: 2 + target: 5 + week: + count: 10 + target: 35 + month: + count: 15 + target: 100 + year: + count: 15 + target: 100 + to_date: + count: 15 + target: 100 + created_at: + type: string + description: Creation timestamp + updated_at: + type: string + description: Update timestamp + + DepartmentGoalCreate: + type: object + required: [departments, criteria] + properties: + departments: + type: array + items: { type: integer } + criteria: + $ref: '#/components/schemas/GoalsCriteria' + + DepartmentGoalUpdate: + type: object + required: [id] + properties: + id: + type: integer + departments: + type: array + items: { type: integer } + criteria: + $ref: '#/components/schemas/GoalsCriteria' + Passkey: + type: object + properties: + id: + type: integer + credential_id: + type: string + description: Base64URL-encoded credential ID + name: + type: string + nullable: true + algorithm: + type: string + example: ES256 + transports: + type: array + items: + type: string + example: ["usb", "nfc", "ble", "internal"] + sign_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + PasskeyCreateRequest: + type: object + required: [credential_id, public_key, algorithm, transports] + properties: + credential_id: + type: string + description: Base64URL-encoded credential ID returned from WebAuthn + public_key: + type: string + description: Base64URL-encoded public key (COSE or PEM as stored) + algorithm: + type: string + example: ES256 + transports: + type: array + items: + type: string + name: + type: string + nullable: true + PasskeyRenameRequest: + type: object + required: [name] + properties: + name: + type: string + + BirdVoiceCall: + type: object + properties: + id: + type: string + format: uuid + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + workspaceId: + type: string + format: uuid + example: "3d5fae4f-9c2d-41aa-9840-28b18e6a94bc" + channelId: + type: string + format: uuid + example: "a2545e48-fe8c-5741-9bdc-42a081076bc9" + callFlowId: + type: string + format: uuid + nullable: true + originator: + type: object + additionalProperties: true + receiver: + type: object + additionalProperties: true + from: + type: string + example: "+4532330288" + to: + type: string + example: "+4542331128" + direction: + type: string + example: "outgoing" + status: + type: string + example: "completed" + type: + type: string + example: "pstn" + duration: + type: integer + example: 3 + hangupCauseCode: + type: integer + nullable: true + hangupSource: + type: string + nullable: true + sipInsights: + type: object + additionalProperties: true + qualityInsights: + type: object + additionalProperties: true + price: + type: object + additionalProperties: true + createdAt: { type: string, format: date-time, nullable: true } + updatedAt: { type: string, format: date-time, nullable: true } + ringingAt: { type: string, format: date-time, nullable: true } + answeredAt: { type: string, format: date-time, nullable: true } + endedAt: { type: string, format: date-time, nullable: true } + + BirdVoiceCallCommandCondition: + type: object + properties: + variable: { type: string } + operator: { type: string } + value: { type: string } + + BirdVoiceCallCommandResult: + type: object + properties: + id: + type: string + format: uuid + callId: + type: string + format: uuid + callFlowId: + type: string + format: uuid + nullable: true + status: + type: string + command: + type: string + conditions: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCallCommandCondition' + + BirdVoiceCallBridgeResult: + allOf: + - $ref: '#/components/schemas/BirdVoiceCallCommandResult' + - type: object + properties: + bridgeCallId: + type: string + format: uuid + nullable: true + + BirdVoiceCallRecording: + type: object + properties: + id: + type: string + format: uuid + callId: + type: string + format: uuid + status: + type: string + example: ongoing + duration: + type: integer + nullable: true + stereo: + type: boolean + nullable: true + mediaUrl: + type: string + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + + BirdVoiceCallInsights: + type: object + description: Voice call insights payload as returned by Bird. + additionalProperties: true + + BirdFlashCall: + type: object + properties: + id: + type: string + format: uuid + workspaceId: + type: string + format: uuid + nullable: true + channelId: + type: string + format: uuid + nullable: true + from: + type: string + nullable: true + to: + type: string + nullable: true + receivedCli: + type: string + nullable: true + result: + type: string + nullable: true + status: + type: string + nullable: true + duration: + type: integer + nullable: true + createdAt: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + + BirdVoiceCallListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + example: "WzE3NzIxNTY5NTI0MDUsIjk5ZDU4M2VkLTQyMzAtNDExNy1hOTQ0LTllY2JjNzhmYWJlMSJd" + results: + type: array + items: { $ref: '#/components/schemas/BirdVoiceCall' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: { $ref: '#/components/schemas/BirdVoiceCall' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallCommandResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallCommandResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallBridgeResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallBridgeResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallRecordingListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCallRecording' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallRecordingSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallRecording' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallInsightsResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdVoiceCallInsights' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallsLogResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdVoiceCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdTestOutboundCallRequest: + type: object + additionalProperties: false + properties: + from: + type: string + description: Caller E.164 number to use for the test call + example: "+4599988877" + to: + type: string + description: Target E.164 number. Defaults to configured test number if omitted. + example: "+4542331128" + timeout: + type: integer + minimum: 1 + description: Backward-compatible alias mapped to ringTimeout + ringTimeout: + type: integer + minimum: 3 + maximum: 120 + pollIntervalSeconds: + type: integer + minimum: 1 + description: Poll interval while waiting for accepted status + example: 2 + maxPollSeconds: + type: integer + minimum: 5 + description: Max time to wait before timing out + example: 30 + hangupCause: + type: string + enum: [rejected, busy] + description: Optional hangup cause passed through to Bird + + BirdTestOutboundCallResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + to: { type: string, example: "+45 42 33 11 28" } + to_e164: { type: string, example: "+4542331128" } + call_id: { type: string, nullable: true, example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" } + final_status: { type: string, nullable: true, example: "completed" } + hangup_sent: { type: boolean, example: true } + created_call: { $ref: '#/components/schemas/BirdVoiceCall' } + last_call_snapshot: { $ref: '#/components/schemas/BirdVoiceCall' } + hangup_response: { $ref: '#/components/schemas/BirdVoiceCallCommandResult' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdInboundCallWebhookRequest: + type: object + additionalProperties: true + properties: + callId: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + workspaceId: + type: string + format: uuid + channelId: + type: string + format: uuid + status: + type: string + description: Optional inbound call status from webhook payload + example: ongoing + dtmf: + type: string + description: DTMF value when present + example: "5" + digit: + type: string + description: Alternate DTMF field + example: "5" + digits: + type: string + description: Alternate DTMF field + example: "5" + call: + type: object + additionalProperties: true + data: + type: object + additionalProperties: true + event: + oneOf: + - type: string + - type: object + additionalProperties: true + + BirdInboundCallWebhookResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: object + properties: + phase: + type: string + enum: [lock_not_acquired, input_window, timeout_window, terminal_completion] + call_id: + type: string + example: "4015cf84-8028-46a1-a0d9-9213e5bf4f09" + completed: + type: boolean + elapsed_seconds: + type: integer + nullable: true + timeout_seconds: + type: integer + nullable: true + input_received: + type: boolean + nullable: true + input_changed: + type: boolean + nullable: true + last_input: + type: string + nullable: true + answered_at: + type: integer + nullable: true + timeout_announced_at: + type: integer + nullable: true + hangup_sent_at: + type: integer + nullable: true + poll_attempts: + type: integer + nullable: true + poll_error: + type: string + nullable: true + terminal_status: + type: string + nullable: true + reason: + type: string + nullable: true + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdVoiceCallCreateRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + maxDuration: { type: integer, minimum: 1 } + sendKeys: { type: string } + record: { type: boolean } + recordStart: { type: string, enum: [record-from-answer, record-from-ringing] } + flowStart: { type: string, enum: [from-answer, from-ringing] } + stereo: { type: boolean } + callFlow: + type: array + items: + type: object + additionalProperties: true + scheduledFor: { type: string, format: date-time } + notification: + type: object + additionalProperties: false + properties: + url: { type: string } + amdSettings: + type: object + additionalProperties: true + tags: + type: array + items: { type: string } + + BirdVoiceCallUpdateRequest: + type: object + additionalProperties: false + properties: + status: + type: string + enum: [completed] + callFlow: + type: array + items: + type: object + additionalProperties: true + + BirdVoiceCallAnswerRequest: + type: object + additionalProperties: false + properties: {} + + BirdVoiceCallRingingRequest: + type: object + additionalProperties: false + properties: {} + + BirdVoiceCallHangupRequest: + type: object + additionalProperties: false + properties: + cause: + type: string + enum: [rejected, busy] + + BirdVoiceCallPlaybackRequest: + type: object + additionalProperties: false + required: [media] + properties: + media: + type: array + minItems: 1 + items: { type: string } + loop: { type: integer, minimum: 0 } + timeout: { type: integer, minimum: 0 } + pauseMilliseconds: { type: integer, minimum: 0, maximum: 30000 } + + BirdVoiceCallSayRequest: + type: object + additionalProperties: false + required: [text] + properties: + text: { type: string } + locale: { type: string } + voice: { type: string } + loop: { type: integer, minimum: 0 } + timeout: { type: integer, minimum: 0 } + hangup: { type: boolean } + + BirdVoiceCallGatherRequest: + type: object + additionalProperties: false + properties: + maxNumKeys: { type: integer, minimum: 1 } + endKey: { type: string, enum: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] } + timeout: { type: integer, minimum: 0 } + retries: { type: integer, minimum: 0 } + input: { type: string, enum: [dtmf, speech, 'dtmf speech'] } + speechLocale: { type: string } + playback: + $ref: '#/components/schemas/BirdVoiceCallPlaybackRequest' + say: + $ref: '#/components/schemas/BirdVoiceCallSayRequest' + + BirdVoiceCallBridgeRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + maxDuration: { type: integer, minimum: 1 } + ringTone: { type: string } + hangupAfterBridge: { type: boolean } + record: { type: boolean } + recordStart: { type: string, enum: [record-from-answer, record-from-ringing] } + recordStereo: { type: boolean } + callFlow: + type: array + items: + type: object + additionalProperties: true + notification: + type: object + additionalProperties: false + properties: + url: { type: string } + amdSettings: + type: object + additionalProperties: true + + BirdVoiceCallRecordRequest: + type: object + additionalProperties: false + properties: + endKey: { type: string, enum: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'] } + maxLength: { type: integer, minimum: 1 } + timeout: { type: integer, minimum: 0 } + beep: { type: boolean } + transcribe: { type: boolean } + transcribeLocale: { type: string } + + BirdVoiceCallRecordingCreateRequest: + type: object + additionalProperties: false + properties: + maxLength: { type: integer, minimum: 1 } + stereo: { type: boolean } + + BirdVoiceCallRecordingUpdateRequest: + type: object + additionalProperties: false + required: [status] + properties: + status: + type: string + enum: [paused, ongoing, completed] + + BirdFlashCallCreateRequest: + type: object + additionalProperties: false + required: [to] + properties: + from: { type: string } + to: { type: string } + ringTimeout: { type: integer, minimum: 3, maximum: 120 } + + BirdFlashCallEndRequest: + type: object + additionalProperties: false + required: [result] + properties: + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + + BirdFlashCallHangupRequest: + oneOf: + - type: object + additionalProperties: false + required: [result] + properties: + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + - type: object + additionalProperties: false + required: [from, to] + properties: + from: { type: string } + to: { type: string } + receivedCli: { type: string } + result: + type: string + enum: [unknown, verified, canceled, timeout, wrong_cli] + + BirdFlashCallListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + nextPageToken: + type: string + nullable: true + results: + type: array + items: + $ref: '#/components/schemas/BirdFlashCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdFlashCallSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdFlashCall' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdFlashCallHangupResult: + type: object + properties: + id: + type: string + format: uuid + nullable: true + result: + type: string + nullable: true + receivedCli: + type: string + nullable: true + from: + type: string + nullable: true + to: + type: string + nullable: true + + BirdFlashCallHangupResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + $ref: '#/components/schemas/BirdFlashCallHangupResult' + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdNumber: + type: object + properties: + id: + type: string + example: "019c73dc-60f1-76c4-98b0-c4318706b938" + workspaceId: + type: string + example: "3d5fae4f-9c2d-41aa-9840-28b18e6a94bc" + type: + type: string + example: "national" + country: + type: string + example: "DK" + number: + type: string + example: "+4532330288" + status: + type: string + example: "active" + capabilities: + type: object + properties: + voice: { $ref: '#/components/schemas/BirdNumberCapability' } + sms: { $ref: '#/components/schemas/BirdNumberCapability' } + mms: { $ref: '#/components/schemas/BirdNumberCapability' } + fax: { $ref: '#/components/schemas/BirdNumberCapability' } + whatsapp: { $ref: '#/components/schemas/BirdNumberCapability' } + monthlyRecurringPrice: + type: object + properties: + currencyCode: { type: string, example: "EUR" } + amount: { type: integer, example: 1000000 } + exponent: { type: integer, example: -6 } + complianceRequirements: + type: array + items: + type: object + additionalProperties: true + configurations: + type: object + additionalProperties: true + createdAt: { type: string, format: date-time, example: "2026-02-19T03:05:48.529Z" } + updatedAt: { type: string, format: date-time, example: "2026-02-19T03:08:17.753Z" } + activatedAt: { type: string, format: date-time, nullable: true, example: "2026-02-19T03:05:48.529Z" } + deactivatedAt: { type: string, format: date-time, nullable: true } + deactivatesAt: { type: string, format: date-time, nullable: true } + subscription: + type: object + additionalProperties: true + endpointSubscription: + type: object + additionalProperties: true + requirements: + type: array + items: + type: object + additionalProperties: true + whatsApp: + type: object + additionalProperties: true + endpoint: + type: object + additionalProperties: true + + BirdNumberCapability: + type: object + properties: + inbound: { type: boolean } + outbound: { type: boolean } + + BirdNumberListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: object + properties: + results: + type: array + items: { $ref: '#/components/schemas/BirdNumber' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + BirdNumberSingleResponse: + type: object + properties: + success: { type: boolean, example: true } + data: { $ref: '#/components/schemas/BirdNumber' } + meta: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + includes: + oneOf: + - type: array + items: {} + - type: object + additionalProperties: true + example: [] + + diff --git a/services/nginx/app/routes/economicInvoiceRoute.php b/services/nginx/app/routes/economicInvoiceRoute.php index bb1226b9..844cf359 100644 --- a/services/nginx/app/routes/economicInvoiceRoute.php +++ b/services/nginx/app/routes/economicInvoiceRoute.php @@ -3,14 +3,13 @@ namespace routes; use classes\authentication; +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; class economicInvoiceRoute @@ -23,118 +22,54 @@ class economicInvoiceRoute /** @var router $router */ $router, $response; - $this->post('/economic/invoice/draft/export', function () { global $response; $this->requirePermission('economic_invoice_draft_export'); $user = (new authentication())->get_user(); - if ($user) { - $order_id = $response->getRequestParameter('order_id'); - if (!isset($order_id)) { - $response->error('Order ID is required', 400); - } - // Validate the order ID is a number - if (!is_numeric($order_id)) { - $response->error('Order ID must be a number', 400); - } - $order = (new orders_o())->getOrderById($order_id); - // Check if the order exists - if (!$order->exists()) { - $response->error('Order not found', 404); - } - // Get the order items - $order_items = (new orders_o())->getOrderItems($order_id); - // Apply the department pricing - $order_items = (new orders_o())->applyDepartmentPrices($order_items, $order->department_id->value()); - // Get the customer - $customer = (new orders_o())->getCustomerByOrderId($order_id); - // Check if the customer exists - if (!$customer->exists()) { - $response->error('Customer not found', 404); - } - // Get the customer economic number - $customer_economic = $customer->getCustomerEcocomicData()->economic_customer; - $economic_invoice_draft = (new economic_invoice_draft_mo()); - // Set the customer number - $economic_invoice_draft->setCustomerNumber((int)$customer_economic->customer_number); - // Set the recipient - $economic_invoice_draft->setRecipient( - $customer_economic->name ?? 'Ukendt', - $customer_economic->address ?? 'Ukendt', - $customer_economic->zip ?? 'Ukendt', - $customer_economic->city ?? 'Ukendt' - ); - // Make sure there are order items - if (count($order_items) === 0) { - $response->error('No order items found', 404); - } - // Get the department - $department = (new departments_o())->getDepartmentById($order->department_id->value()); - // Add the department, date, reference - $this->addTheDepartmentDateReference($economic_invoice_draft, $department['name'], $order); - - // Add the lines to the invoice - foreach ( $order_items as $order_item ) { - // Add the order item to the invoice draft - $this->addOrderItemToInvoice( - $customer, - $order, - $order_item, - $economic_invoice_draft, - $order_item['quantity'] ?? 1); - } - // Check if the user has an open invoice draft - $hasOpenInvoiceDraft = $customer->hasOpenInvoiceDraft(); - if ($hasOpenInvoiceDraft && !$customer->invoicePerOrder()) { - // Get the open invoice draft - $openInvoiceDraft = $customer->getOpenInvoiceDraft(); - // Add the order to the invoice draft - $result = $this->addOrderToInvoiceDraft($openInvoiceDraft, $order, $customer, $order_items); - } - // If the customer doesn't want to be billed per order, or if there's no open invoice draft, we'll create a new one - // Create the invoice draft - if (!isset($result)) { - $result = $economic_invoice_draft->createInvoiceDraftExample(); - } - // Check if the invoice draft was created, or if the lines were added (lines is an array, and should not be empty) - if (!isset($result->draftInvoiceNumber) && !isset($result->lines[0])) { - // Log the error - (new logs_o())->add('economic_invoice_draft', 'global', 3, $user->id, 'ECONOMIC_INVOICE_DRAFT_EXPORT', 'Failed to create economic invoice draft'); - // Check if we can get the errors from the response - if (isset($result->errors)) { - $response->add_meta('economic_errors', $result->errors); - } - $response->add_meta('economic_result', $result); - // Try to parse the error message - $response->error($result->message ?? 'Failed to create economic invoice draft', 500); - } - // Add the economic invoice draft to the order - $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); - // Remove the existing invoice draft (if any) - if ($economic_module_orders->economic_invoice_draft_id->value() > 0) { - $economic_invoice_draft->deleteInvoiceDraft($economic_module_orders->economic_invoice_draft_id->value()); - } - $economic_module_orders->economic_invoice_draft_id->set($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft()); - if (!$customer->invoicePerOrder()) { - // If the customer wants to be billed per order, we'll add the order to the invoice draft - $customer->setOpenInvoiceDraft($result->draftInvoiceNumber ?? $customer->getOpenInvoiceDraft()); - } else { - // If the customer doesn't want to be billed per order, we'll remove the open invoice draft - $customer->unsetOpenInvoiceDraft(); - } - // Return the response - (new logs_o())->add('economic_invoice_draft', 'global', 1, $user->id, 'ECONOMIC_INVOICE_DRAFT_EXPORT', 'Successfully exported an economic invoice draft'); - $response->success($economic_module_orders->getArray()); - } else { + 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); } + $this->ensureEconomicTransferQueueIsAvailable(); + + $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); + } + + $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 () { - global /** @var response $response */ $response; $this->requirePermission('economic_invoice_draft_delete'); @@ -145,34 +80,25 @@ class economicInvoiceRoute $response->error('Order ID is required', 400); } $order = (new orders_o())->getOrderById($order_id); - // Check if the order exists if (!$order->exists()) { $response->error('Order not found', 404); } - // Make sure the order has an economic invoice draft $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()); - // Get the invoice draft number $invoiceDraftId = $economic_module_orders->economic_invoice_draft_id->value(); $customer = (new orders_o())->getCustomerByOrderId($order_id); if (!$customer->invoicePerOrder()) { - // Check if the customer has an open invoice draft if ($customer->hasOpenInvoiceDraft()) { - // Check if it's the same as the order's invoice draft if ((int)$customer->getOpenInvoiceDraft() === (int)$invoiceDraftId) { - // Remove the open invoice draft from the customer $customer->deleteOpenInvoiceDraft(); } } } - // Delete the invoice draft $economic_invoice_draft->deleteInvoiceDraft($invoiceDraftId); - // Remove the economic invoice draft from the order $economic_module_orders->economic_invoice_draft_id->set(null); - // Return the response (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 { @@ -187,216 +113,189 @@ class economicInvoiceRoute global $response; $this->requirePermission('economic_invoice_export'); $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); - // Check if the order exists - if (!$order->exists()) { - $response->error('Order not found', 404); - } - // Get the customer - $customer = (new orders_o())->getCustomerByOrderId($order_id); - // Check if the customer exists - if (!$customer->exists()) { - $response->error('Customer not found', 404); - } - // Make sure the order has an economic invoice draft - $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()); - // Get the invoice draft number - $invoiceDraftId = $economic_module_orders->economic_invoice_draft_id->value(); - // Make sure there's not already an invoice created - $invoiceId = $economic_module_orders->economic_invoice_id->value(); - if ($invoiceId > 0) { - $response->error('An invoice has already been created, invoice ID: ' . $invoiceId, 400); - } - // Publish the invoice draft - $result = $economic_invoice_draft->publishInvoiceDraft((int)$invoiceDraftId); - // Check if the invoice was created - if (!isset($result->bookedInvoiceNumber)) { - // Log the error - (new logs_o())->add('economic_invoice', 'global', 3, $user->id, 'ECONOMIC_INVOICE_EXPORT', 'Failed to create economic invoice from draft: ' . $invoiceDraftId); - // Check if we can get the errors from the response - if (isset($result->errors)) { - $response->add_meta('economic_errors', $result->errors); - } - // Try to parse the error message - $response->error($result->message ?? 'Failed to create economic invoice', 500); - } - // Add the economic invoice to the order - $economic_module_orders = (new economic_module_orders())->getByOrderId($order_id); - $economic_module_orders->economic_invoice_id->set($result->bookedInvoiceNumber); - // Remove the economic invoice draft from the customer - $customer->unsetOpenInvoiceDraft(); - // Return the response - (new logs_o())->add('economic_invoice', 'global', 1, $user->id, 'ECONOMIC_INVOICE_EXPORT', 'Successfully exported an economic invoice'); - $response->success($economic_module_orders->getArray()); - } else { + 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); } + $this->ensureEconomicTransferQueueIsAvailable(); + + $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); + } + $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); + } + + $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 () { + 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->getJobById((int)$job_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 () { + 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->getJobById((int)$job_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->retryJob((int)$job_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 () { + 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->getJobById((int)$job_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 () { + 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->getJobById((int)$job_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->retryJob((int)$job_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' + ]); } - /** - * @param economic_invoice_draft_mo $economic_invoice_draft - * @param $department_name - * @param orders_o $order - * @return void - */ - function addTheDepartmentDateReference(economic_invoice_draft_mo $economic_invoice_draft, $department_name, orders_o $order): void + private function ensureEconomicTransferQueueIsAvailable(): void { - // The format is: - // Truck Wash - [department name], [date], (?)Ref(erence): [reference], Reg 1: [reg_1], (?)Reg 2: [reg_2], (?)Reg 3: [reg_3] - // (?)[note] - // Example: - // 12-02-2025 13:27, Truck Wash - Administration, Ref: 123456, Reg 1: ABC123, Reg 2: DEF456, Reg 3: GHI789 - // This is a note for the invoice - // - // (?) = Optional - $parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value())); - $economic_invoice_draft->addLineTEXT("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]"); - // If there's a reference, add it to the invoice - if ($order->reference->value() !== '') { - $economic_invoice_draft->addLineTEXT('Reference:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - 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()); - } - } - // Add the registration numbers (if any) - $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()); - // Add the line to the invoice - $economic_invoice_draft->addLineTEXT($line_reg); - // If there's a note, add it to the invoice - if ($order->notes->value() !== '') { - $economic_invoice_draft->addLineTEXT('Notat:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - 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()); - } + global $response; + + if ( + !class_exists('\\classes\\economic_transfer_executor') + || !class_exists('\\classes\\economic_transfer_queue_schema_bootstrap') + || !class_exists(economic_transfer_queue::class) + ) { + $response->error('Economic transfer queue is unavailable in this deployment', 503); } } - - /** - * @param users_o $customer - * @param orders_o $order - * @param mixed $order_item - * @param economic_invoice_draft_mo $economic_invoice_draft - * @param int $quantity - * @return void - * @throws \Exception - */ - function addOrderItemToInvoice(users_o $customer, orders_o $order, mixed $order_item, economic_invoice_draft_mo $economic_invoice_draft, int $quantity = 1): void - { - // The format is: - // [product name], (?)Ref(erence): [reference], (!?)Reg 1: [reg 1], (!?)Reg 2: [reg 2], (!?)Reg 3: [reg 3], (?)Note: [note], (?)Discount: ([order item price] - [product price]) DKK ([discount percentage] %) - // (?) = Optional - // (!) = Required if the customer requires it - // (!?) = Should be added if the customer requires it - // Example: - // Tankcleaning 4 spulehoveder, Ref: 123456, Reg 1: ABC123, Reg 2: DEF456, Reg 3: GHI789, Note: This is a note, Discount: -100 DKK (20%) - // Get the department - $department = $order->getDepartmentByOrderId($order->id); - $economic_department_id = $department['economic_department_id']; - $economic_dimension_id = $department['economic_dimension_id'] ?? 0; - - // Add the line to the invoice - $economic_invoice_draft->addLine( - (string)$order_item['product']['economic_product_id'], - (string)$order_item['product']['name'], - (int)$quantity, - (int)$order_item['price'], - 0, // Since we can't be specific about the discount, we set it to 0. (The API has a limit of 2 decimals, and that's not enough for our needs) - (int)$economic_department_id ?? 0, - (int)$economic_dimension_id ?? 0 // If the department is not set, we'll set it to 0 - ); - // Calculate the discount percentage - $discountPercentage = round((($order_item['product']['price'] - $order_item['price']) / $order_item['product']['price']) * 100, 0); - - // If the price is different from the product price, add it to the line - if ($order_item['price'] !== $order_item['product']['price']) - $economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item['price'] - $order_item['product']['price']) . ' DKK (' . $discountPercentage . '%)'); - - // If there's a reference, add it to the line - if ($order_item['reference'] !== '') { - $economic_invoice_draft->addLineTEXT('Reference:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - 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']); - } - } - - // Add the registration numbers (if they exist, and the customer requires it) - 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 there's a note, add it to the line - if ($order_item['notes'] !== '') { - $economic_invoice_draft->addLineTEXT('Notat:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - 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']); - } - } - - } - - 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()); - // Add two empty lines to the invoice, to separate the orders - $economic_invoice_draft->addLineTEXT(''); - $economic_invoice_draft->addLineTEXT(''); - // Add the department, date, reference - $this->addTheDepartmentDateReference($economic_invoice_draft, $order->getDepartmentByOrderId($order->id)['name'], $order); - // Add the lines to the invoice - foreach ( $order_items as $order_item ) { - // Add the order item to the invoice draft - $this->addOrderItemToInvoice($customer, $order, $order_item, $economic_invoice_draft, $order_item['quantity'] ?? 1); - } - return $economic_invoice_draft->addLinesToInvoiceDraft($economic_invoice_draft_id); - } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index e2c1c282..d2d4a1b2 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -4,6 +4,7 @@ namespace routes; use classes\authentication; use classes\economic; +use classes\economic_transfer_queue; use classes\economic_v2_compare_engine; use classes\economic_v2_line_normalizer; use classes\economic_v2_revenue_statistics_service; @@ -13,6 +14,7 @@ use Exception; use objects\collected_order_invoices_o; use objects\customer_fixed_pricing_o; use objects\logs_o; +use objects\orders_o; use objects\users_o; use traits\route_t; @@ -522,77 +524,75 @@ class orderInvoicesRoute ] ); - /** Collected order invoices > E-Conomic > POST */ + /** Collected order invoices > E-Conomic > POST (queued) */ $this->post('/collected-invoices/economic', function () { global $response; self::requirePermission('add_collected_invoice_economic'); $user = (new authentication())->get_user(); if ($user) { - (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC', 'User added a collected order invoice to E-Conomic'); - // Require the ID, and validate its type and length self::requireParameters(['id']); self::requireType((int)self::getParameter('id'), self::type_int()); self::requireMinLength('id', 1); self::requireMaxLength('id', 10); - // Require the ID to be above 0 self::requireMinValue((int)self::getParameter('id'), 1); - // If the send_as_is parameter is set, validate its type - $send_as_is = false; // This is false by default, when true it will send the invoice as is, without adding vehicle subscriptions or fixed pricing. This is used when a customer with fixed pricing has already been invoiced with the fixed price, and we just need to send the invoice to E-Conomic. + + $send_as_is = false; if (self::isParametersSet(['send_as_is'])) { - self::requireType((bool)self::getParameter('send_as_is'), self::type_bool()); - $send_as_is = (bool)self::getParameter('send_as_is'); + $send_as_is_raw = self::getParameter('send_as_is'); + if (is_bool($send_as_is_raw)) { + $send_as_is = $send_as_is_raw; + } elseif (is_numeric($send_as_is_raw)) { + $send_as_is = ((int)$send_as_is_raw) === 1; + } elseif (is_string($send_as_is_raw)) { + $normalized_send_as_is = strtolower(trim($send_as_is_raw)); + if (!in_array($normalized_send_as_is, ['true', 'false', '1', '0'], true)) { + $response->error('send_as_is must be a boolean', 400); + } + $send_as_is = in_array($normalized_send_as_is, ['true', '1'], true); + } else { + $response->error('send_as_is must be a boolean', 400); + } } - // Validate the ID against the database + $collected_order_invoices = (new collected_order_invoices_o())->select((int)self::getParameter('id')); $collected_order_invoices->requireSelected(); - // Check if the collected order invoice has an external ID - if ($collected_order_invoices->external_id->value() === null) { - // Check if the send_as_is parameter is set to true - if (!$send_as_is) { - // Add fixed price to the collected order invoice - $customer_fixed_pricing_o = new customer_fixed_pricing_o(); - if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { - $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); - $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); - $collected_order_invoices->overridePricesFixed((int)$customer_fixed_pricing_price); - } else { - // Apply vehicle subscriptions if the customer does not have fixed pricing - $collected_order_invoices->addVehicleSubscriptionsTransaction(); - } - } else { - // Since we are sending the invoice as is, we need to check if the invoice has any left-over subscription / fixed price transactions - $collected_order_invoices->removeSpecialArrangements(); // Remove any left-over special arrangement transactions (subscriptions / fixed price) - // Reset the price of all items set not to be included in the invoice - //$collected_order_invoices->resetPricesOfItemsNotIncludedInInvoice(); - // Set all items to be included in the invoice - $collected_order_invoices->setAllItemsToBeIncludedInInvoice(); // Set all items to be included in the invoice, since we are sending the invoice as is. - } - // Add the collected order invoice to E-Conomic - $collected_order_invoices->addToEconomic(); - $response->success($collected_order_invoices->asArray()); - } - // Check if the invoice has been booked + if ($collected_order_invoices->booked_invoice_id->value() !== null) { $response->error('Invoice has already been booked', 400); } - // Check if the invoice draft exists in E-Conomic - if ($collected_order_invoices->isDraftExisting()) { - $response->error('Invoice draft already exists in E-Conomic', 400); + + if (!$this->isEconomicTransferQueueAvailable()) { + try { + $result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC', 'Processed collected invoice transfer synchronously (queue unavailable)'); + $response->success($result); } - // Add fixed price to the collected order invoice - $customer_fixed_pricing_o = new customer_fixed_pricing_o(); - if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { - $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); - $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); - $collected_order_invoices->overridePricesFixed((int)$customer_fixed_pricing_price); - } else { - // Apply vehicle subscriptions if the customer does not have fixed pricing - $collected_order_invoices->addVehicleSubscriptionsTransaction(); + + $queue = new economic_transfer_queue(); + $job = $queue->enqueue( + economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT, + [ + 'collected_invoice_id' => (int)self::getParameter('id'), + 'send_as_is' => $send_as_is, + 'requested_by' => (int)$user->id, + ], + (int)$user->id + ); + $job_id = (int)($job['id'] ?? 0); + if ($job_id < 1) { + $response->error('Failed to enqueue collected invoice export job: missing queue job id in enqueue response', 500); } - // Create the invoice in E-Conomic - $collected_order_invoices->addToEconomic(true); - // Return the collected order invoice - $response->success($collected_order_invoices->asArray()); + + (new logs_o())->add('orderInvoices', 'global', 1, $user->id, 'ADD_COLLECTED_INVOICE_ECONOMIC_QUEUED', 'Queued collected invoice transfer to E-Conomic'); + $response->success([ + 'message' => 'Collected invoice export queued', + 'job_id' => $job_id, + 'job' => $job, + ], 202); } else { (new logs_o())->add('orderInvoices', 'global', 0, 0, 'ADD_COLLECTED_INVOICE_ECONOMIC', 'User tried to add a collected order invoice to E-Conomic without a valid session'); $response->error('Invalid session', 400); @@ -603,6 +603,232 @@ class orderInvoicesRoute ] ); + $this->get('/collected-invoices/economic/queue', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + $statuses = []; + if (self::isParametersSet(['status'])) { + $status_raw = (string)self::getParameter('status'); + $statuses = array_values(array_filter(array_map('trim', explode(',', $status_raw)))); + } + $limit = self::isParametersSet(['limit']) ? (int)self::getParameter('limit') : 50; + $offset = self::isParametersSet(['offset']) ? (int)self::getParameter('offset') : 0; + + $queue = new economic_transfer_queue(); + $jobs = $queue->listJobs($statuses, $limit, $offset); + $filtered = array_values(array_filter($jobs, static function (array $job): bool { + return ($job['transfer_type'] ?? null) === economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT; + })); + + $response->success([ + 'items' => $filtered, + 'count' => count($filtered), + ]); + }, + [ + 'add_collected_invoice_economic' => 'List queued collected invoice transfer jobs.' + ] + ); + + $this->get('/collected-invoices/economic/queue/status', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + self::requireParameters(['job_id']); + self::requireType((int)self::getParameter('job_id'), self::type_int()); + self::requireMinValue((int)self::getParameter('job_id'), 1); + + $queue = new economic_transfer_queue(); + $job = $queue->getJobById((int)self::getParameter('job_id')); + if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) { + $response->error('Collected invoice queue job not found', 404); + } + $response->success($job); + }, + [ + 'add_collected_invoice_economic' => 'Get queued collected invoice transfer job status.' + ] + ); + + $this->post('/collected-invoices/economic/queue/retry', function () { + global $response; + self::requirePermission('add_collected_invoice_economic'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + $this->ensureEconomicTransferQueueIsAvailable(); + + self::requireParameters(['job_id']); + self::requireType((int)self::getParameter('job_id'), self::type_int()); + self::requireMinValue((int)self::getParameter('job_id'), 1); + + $queue = new economic_transfer_queue(); + $job = $queue->getJobById((int)self::getParameter('job_id')); + if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) { + $response->error('Collected invoice queue job not found', 404); + } + + try { + $retried = $queue->retryJob((int)self::getParameter('job_id')); + } catch (\Throwable $e) { + $response->error($e->getMessage(), 400); + } + + $response->success([ + 'message' => 'Collected invoice queue job retried', + 'job' => $retried, + ]); + }, + [ + 'add_collected_invoice_economic' => 'Retry failed queued collected invoice transfer job.' + ] + ); + + /** Collected order invoices > Move multiple > Registration numbers > POST */ + $this->post('/collected-invoices/move-multiple/registration-numbers', function () { + global $response; + self::requirePermission('move_collected_invoice'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + self::requireParameters(['registration_numbers', 'target_customer_number', 'from_date', 'to_date', 'closed_at']); + $registration_numbers = self::getParameter('registration_numbers'); + if (!is_array($registration_numbers) || empty($registration_numbers)) { + $response->error('registration_numbers must be a non-empty array', 400); + } + $target_customer_number = self::getParameter('target_customer_number'); + self::requireType((int)$target_customer_number, self::type_int()); + self::requireMinValue((int)$target_customer_number, 1); + $closed_at = self::getParameter('closed_at'); + if (!!$closed_at) { + self::requireDateFormat($closed_at, self::FORMAT_DATE()); + $closed_at = date('Y-m-d H:i:s', strtotime($closed_at . ' 23:59:59')); + } else { + $closed_at = null; + } + $from_date = self::getParameter('from_date'); + self::requireDateFormat($from_date, self::FORMAT_DATE()); + $to_date = self::getParameter('to_date'); + self::requireDateFormat($to_date, self::FORMAT_DATE()); + $from_date = date('Y-m-d 00:00:00', strtotime($from_date)); + $to_date = date('Y-m-d 23:59:59', strtotime($to_date)); + if (strtotime($from_date) > strtotime($to_date)) { + $response->error('from_date must be before or equal to to_date', 400); + } + $customer = (new users_o())->getUserByCustomerNumber((int)$target_customer_number); + $customer->requireSelected(); + + $collected_order_invoices = new collected_order_invoices_o(); + $moved_invoices = []; + // Create a new collected order invoice for the target customer + $new_invoice_collection = $collected_order_invoices->add( + $customer->customer_number->value(), + null, + null, + null, + $closed_at + ); + if (!$new_invoice_collection->exists()) { + $response->error('Failed to create new invoice collection'); + } + + foreach ($registration_numbers as $registration_number) { + if (!is_string($registration_number) || trim($registration_number) === '') { + $response->error('Each registration number must be a non-empty string', 400); + } + $orders = (new orders_o())->getOrdersWithRegistrationNumberInDateRange($registration_number, $from_date, $to_date); + if (empty($orders)) { + continue; // Skip if no orders found for this registration number in the date range + } + foreach ($orders as $order) { + $order->customer_id->set((int)$new_invoice_collection->customer_number->value()); + $order->assignToInvoiceCollection((int)$new_invoice_collection->id); + $order->objectChanged(); + $moved_invoices[] = $order->id; + } + } + $response->success([ + 'message' => 'Orders moved to new invoice collection', + 'moved_invoices' => $moved_invoices, + 'from_date' => $from_date, + 'to_date' => $to_date, + 'target_customer_number' => $target_customer_number, + ]); + }); + /** Collected order invoices > Move multiple > POST */ + $this->post('/collected-invoices/move-multiple', function () { + // Used to move multiple orders, to a new collected order invoice, in one request, instead of having to move each order one by one + global $response; + self::requirePermission('move_collected_invoice'); + $user = (new authentication())->get_user(); + if (!$user) { + $response->error('Invalid session', 400); + } + self::requireParameters(['order_ids', 'target_customer_number', 'closed_at']); + $order_ids = self::getParameter('order_ids'); + if (!is_array($order_ids) || empty($order_ids)) { + $response->error('order_ids must be a non-empty array', 400); + } + $target_customer_number = self::getParameter('target_customer_number'); + self::requireType((int)$target_customer_number, self::type_int()); + self::requireMinValue((int)$target_customer_number, 1); + $closed_at = self::getParameter('closed_at'); + if (!!$closed_at) { + self::requireDateFormat($closed_at, self::FORMAT_DATE()); + $closed_at = date('Y-m-d H:i:s', strtotime($closed_at . ' 23:59:59')); + } else { + $closed_at = null; + } + $customer = (new users_o())->select((int)$target_customer_number); + $customer->requireSelected(); + + $collected_order_invoices = new collected_order_invoices_o(); + $moved_invoices = []; + // Create a new collected order invoice for the target customer + $new_invoice_collection = $collected_order_invoices->add( + $customer->id, + null, + null, + null, + $closed_at + ); + if (!$new_invoice_collection->exists()) { + $response->error('Failed to create new invoice collection'); + } + foreach ($order_ids as $order_id) { + self::requireType((int)$order_id, self::type_int()); + self::requireMinValue((int)$order_id, 1); + $order = (new orders_o())->select((int)$order_id); + $order->requireSelected(); + $order->customer_id->set((int)$new_invoice_collection->customer_number->value()); + $order->objectChanged(); + $order->assignToInvoiceCollection((int)$new_invoice_collection->id); + $moved_invoices[] = $order->id; + } + $new_invoice_collection->clearCachedData(); + $response->success([ + 'message' => 'Orders moved to new invoice collection', + 'moved_invoices' => $moved_invoices, + ]); + }, + [ + 'move_collected_invoice' => 'Move multiple orders to a new collected order invoice. This is a superuser-only route.' + ] + ); + /** Collected order invoices > E-Conomic > Unlink and clear cached data > POST */ $this->post('/collected-invoices/economic/unlink', function () { global $response; @@ -1461,6 +1687,68 @@ class orderInvoicesRoute return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true); } + /** + * Synchronous fallback when queue components are unavailable in this deployment. + * Mirrors the collected-invoice flow used by the queue executor. + * @throws Exception + */ + private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array + { + if ($collected_order_invoices->external_id->value() === null) { + if (!$send_as_is) { + $customer_fixed_pricing_o = new customer_fixed_pricing_o(); + if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { + $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); + $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); + $collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price); + } else { + $collected_order_invoices->addVehicleSubscriptionsTransaction(); + } + } else { + $collected_order_invoices->removeSpecialArrangements(); + $collected_order_invoices->setAllItemsToBeIncludedInInvoice(); + } + + $collected_order_invoices->addToEconomic(); + } else { + if ($collected_order_invoices->booked_invoice_id->value() !== null) { + throw new Exception('Invoice has already been booked'); + } + if ($collected_order_invoices->isDraftExisting()) { + throw new Exception('Invoice draft already exists in E-Conomic'); + } + + $customer_fixed_pricing_o = new customer_fixed_pricing_o(); + if ($customer_fixed_pricing_o->doesUserHaveFixedPricing((int)$collected_order_invoices->customer_number->value())) { + $customer_fixed_pricing_price = $customer_fixed_pricing_o->selectByCustomerNumber((int)$collected_order_invoices->customer_number->value()); + $customer_fixed_pricing_price = (int)$customer_fixed_pricing_price->price->value(); + $collected_order_invoices->overridePricesFixed($customer_fixed_pricing_price); + } else { + $collected_order_invoices->addVehicleSubscriptionsTransaction(); + } + + $collected_order_invoices->addToEconomic(true); + } + + return $collected_order_invoices->asArray(); + } + + 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); + } + + private function ensureEconomicTransferQueueIsAvailable(): void + { + global $response; + + if (!$this->isEconomicTransferQueueAvailable()) { + $response->error('Economic transfer queue is unavailable in this deployment', 503); + } + } + /** * @param $collected_order_invoice * @param users_o $users diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftZeroItemSkipWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftZeroItemSkipWiringTest.php new file mode 100644 index 00000000..9a547904 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftZeroItemSkipWiringTest.php @@ -0,0 +1,10 @@ +not->toBeFalse(); + expect($content)->toContain('private function shouldSkipOrderItemLine(array $order_item): bool'); + expect($content)->toContain('if ($this->shouldSkipOrderItemLine($order_item))'); + expect($content)->toContain('continue;'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferOrderItemSkipTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferOrderItemSkipTest.php new file mode 100644 index 00000000..df486520 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferOrderItemSkipTest.php @@ -0,0 +1,53 @@ + 0, + 'price' => 199, + ], 0); + + expect($shouldSkip)->toBeTrue(); +}); + +it('skips order items with zero price for e-conomic export', function (): void { + $shouldSkip = economic_transfer_executor::shouldSkipOrderItemForInvoice([ + 'quantity' => 2, + 'price' => 0, + ], 2); + + expect($shouldSkip)->toBeTrue(); +}); + +it('keeps positive quantity and price items billable for e-conomic export', function (): void { + $shouldSkip = economic_transfer_executor::shouldSkipOrderItemForInvoice([ + 'quantity' => 2, + 'price' => 149, + ], 2); + + expect($shouldSkip)->toBeFalse(); +}); + +it('skips malformed order-item payloads for e-conomic export safety', function (): void { + expect(economic_transfer_executor::shouldSkipOrderItemForInvoice(null, 1))->toBeTrue(); + expect(economic_transfer_executor::shouldSkipOrderItemForInvoice('invalid', 1))->toBeTrue(); + expect(economic_transfer_executor::shouldSkipOrderItemForInvoice([ + 'quantity' => 1, + ], 1))->toBeTrue(); + expect(economic_transfer_executor::shouldSkipOrderItemForInvoice([ + 'quantity' => 1, + 'price' => 'abc', + ], 1))->toBeTrue(); +}); + +it('wires zero-cost and zero-quantity skip guard into transfer line builder', function (): void { + $content = file_get_contents(app_path('classes/economic_transfer_executor.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('if (self::shouldSkipOrderItemForInvoice($order_item, $quantity))'); + expect($content)->toContain("throw new Exception('No billable order items found');"); + expect($content)->toContain("throw new Exception('Order item is missing economic product id');"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php new file mode 100644 index 00000000..4b34e4a0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueAvailabilityGuardTest.php @@ -0,0 +1,78 @@ +not->toBeFalse(); + + $guardCount = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content); + $queueInitCount = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content); + expect($guardCount)->toBe(6); + expect($queueInitCount)->toBe(6); + + $endpointPatterns = [ + "/\\\$this->post\\('\\/economic\\/invoice\\/draft\\/export'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/economic\\/invoice\\/export'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->get\\('\\/economic\\/invoice\\/draft\\/export\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/economic\\/invoice\\/draft\\/export\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->get\\('\\/economic\\/invoice\\/export\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/economic\\/invoice\\/export\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + ]; + + foreach ($endpointPatterns as $pattern) { + expect(preg_match($pattern, (string)$content))->toBe(1); + } +}); + +it('guards all collected-invoice queue endpoints before constructing queue service', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + + $guardCount = preg_match_all('/\$this->ensureEconomicTransferQueueIsAvailable\(\);/', (string)$content); + $queueInitCount = preg_match_all('/new economic_transfer_queue\(\);/', (string)$content); + expect($guardCount)->toBe(3); + expect($queueInitCount)->toBe(4); + + $endpointPatterns = [ + "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->get\\('\\/collected-invoices\\/economic\\/queue\\/status'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + "/\\\$this->post\\('\\/collected-invoices\\/economic\\/queue\\/retry'.*?\\\$this->ensureEconomicTransferQueueIsAvailable\\(\\);.*?new economic_transfer_queue\\(\\);/s", + ]; + + foreach ($endpointPatterns as $pattern) { + expect(preg_match($pattern, (string)$content))->toBe(1); + } + + expect($content)->toContain('if (!$this->isEconomicTransferQueueAvailable()) {'); + expect($content)->toContain('$result = $this->exportCollectedInvoiceSynchronously($collected_order_invoices, $send_as_is);'); + expect($content)->toContain('private function exportCollectedInvoiceSynchronously(collected_order_invoices_o $collected_order_invoices, bool $send_as_is): array'); + expect($content)->toContain('private function isEconomicTransferQueueAvailable(): bool'); +}); + +it('uses a consistent unavailable-service contract for missing queue dependencies', function (): void { + $invoiceRouteContent = file_get_contents(app_path('routes/economicInvoiceRoute.php')); + $orderInvoicesRouteContent = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($invoiceRouteContent)->not->toBeFalse(); + expect($orderInvoicesRouteContent)->not->toBeFalse(); + + expect($invoiceRouteContent)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void'); + expect($invoiceRouteContent)->toContain("!class_exists('\\\\classes\\\\economic_transfer_executor')"); + expect($invoiceRouteContent)->toContain("!class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')"); + expect($invoiceRouteContent)->toContain('!class_exists(economic_transfer_queue::class)'); + + expect($orderInvoicesRouteContent)->toContain('private function isEconomicTransferQueueAvailable(): bool'); + expect($orderInvoicesRouteContent)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')"); + expect($orderInvoicesRouteContent)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')"); + expect($orderInvoicesRouteContent)->toContain('class_exists(economic_transfer_queue::class)'); + expect($orderInvoicesRouteContent)->toContain('if (!$this->isEconomicTransferQueueAvailable()) {'); + + foreach ([$invoiceRouteContent, $orderInvoicesRouteContent] as $content) { + expect($content)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void'); + expect($content)->toContain("Economic transfer queue is unavailable in this deployment', 503"); + expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';"); + expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';"); + expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue.php';"); + } +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronIntegrationTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronIntegrationTest.php new file mode 100644 index 00000000..efeb3caa --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueCronIntegrationTest.php @@ -0,0 +1,26 @@ +not->toBeFalse(); + expect($content)->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';"); + expect($content)->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';"); + expect($content)->toContain("require_once __DIR__ . '/../classes/economic_transfer_queue.php';"); + expect($content)->toContain("'EconomicTransferQueueCron'"); + expect($content)->toContain("'function' => 'EconomicTransferQueueCron'"); + expect($content)->toContain('function EconomicTransferQueueCron(): void'); + expect($content)->toContain('new economic_transfer_queue()'); + expect($content)->toContain('processPending(10)'); +}); + +it('wires queue worker class loading for CLI queue action', function (): void { + $content = file_get_contents(app_path('cli.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain("require_once __DIR__ . '/classes/economic_transfer_executor.php';"); + expect($content)->toContain("require_once __DIR__ . '/classes/economic_transfer_queue_schema_bootstrap.php';"); + expect($content)->toContain("require_once __DIR__ . '/classes/economic_transfer_queue.php';"); + expect($content)->toContain("case 'economic-transfer-queue':"); + expect($content)->toContain('new \\classes\\economic_transfer_queue()'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php new file mode 100644 index 00000000..044a47f2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueHardeningTest.php @@ -0,0 +1,13 @@ +not->toBeFalse(); + expect($content)->toContain('private const STALE_PROCESSING_LOCK_SECONDS = 900'); + expect($content)->toContain('$this->validateTransferType($transfer_type)'); + expect($content)->toContain('$max_attempts = max(1, min(10, $max_attempts));'); + expect($content)->toContain('Queue job reached max retry attempts'); + expect($content)->toContain('AND attempts < max_attempts'); + expect($content)->toContain('private function releaseStaleProcessingLocks(): void'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php new file mode 100644 index 00000000..028c6c63 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueOpenApiSpecTest.php @@ -0,0 +1,51 @@ +markTestSkipped('openapi.yaml is not available in this runtime environment.'); +} + +it('documents economic transfer queue paths in openapi', function (): void { + $content = economic_transfer_queue_openapi_content_or_skip(); + + expect($content)->toContain('/collected-invoices/economic:'); + expect($content)->toContain('/collected-invoices/economic/queue:'); + expect($content)->toContain('/collected-invoices/economic/queue/status:'); + expect($content)->toContain('/collected-invoices/economic/queue/retry:'); + expect($content)->toContain('/economic/invoice/draft/export/status:'); + expect($content)->toContain('/economic/invoice/draft/export/retry:'); + expect($content)->toContain('/economic/invoice/export/status:'); + expect($content)->toContain('/economic/invoice/export/retry:'); +}); + +it('documents economic transfer queue schemas in openapi', function (): void { + $content = economic_transfer_queue_openapi_content_or_skip(); + + expect($content)->toContain('EconomicTransferQueueStatus:'); + expect($content)->toContain('EconomicTransferQueueJob:'); + expect($content)->toContain('EconomicTransferQueueEnqueueResponse:'); + expect($content)->toContain('EconomicTransferQueueStatusResponse:'); + expect($content)->toContain('EconomicTransferQueueRetryResponse:'); + expect($content)->toContain('EconomicTransferQueueListResponse:'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueRouteRegistrationTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueRouteRegistrationTest.php new file mode 100644 index 00000000..72135d1b --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueRouteRegistrationTest.php @@ -0,0 +1,44 @@ +not->toBeFalse(); + expect($content)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void'); + expect($content)->toContain("!class_exists('\\\\classes\\\\economic_transfer_executor')"); + expect($content)->toContain("!class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')"); + expect($content)->toContain('$this->ensureEconomicTransferQueueIsAvailable();'); + expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';"); + expect($content)->toContain('/economic/invoice/draft/export'); + expect($content)->toContain('/economic/invoice/export'); + expect($content)->toContain('/economic/invoice/draft/export/status'); + expect($content)->toContain('/economic/invoice/draft/export/retry'); + expect($content)->toContain('/economic/invoice/export/status'); + expect($content)->toContain('/economic/invoice/export/retry'); + expect($content)->toContain('economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT'); + expect($content)->toContain('economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT'); + expect($content)->toContain("'job_id' => \$job_id"); + expect($content)->toContain("missing queue job id in enqueue response"); +}); + +it('registers collected-invoice queue endpoints in orderInvoicesRoute', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('private function ensureEconomicTransferQueueIsAvailable(): void'); + expect($content)->toContain('private function isEconomicTransferQueueAvailable(): bool'); + expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_executor')"); + expect($content)->toContain("class_exists('\\\\classes\\\\economic_transfer_queue_schema_bootstrap')"); + expect($content)->toContain('$this->ensureEconomicTransferQueueIsAvailable();'); + expect($content)->not->toContain("require_once __DIR__ . '/../classes/economic_transfer_executor.php';"); + expect($content)->toContain('/collected-invoices/economic'); + expect($content)->toContain('/collected-invoices/economic/queue'); + expect($content)->toContain('/collected-invoices/economic/queue/status'); + expect($content)->toContain('/collected-invoices/economic/queue/retry'); + expect($content)->toContain('economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT'); + expect($content)->toContain("'job_id' => \$job_id"); + expect($content)->toContain("missing queue job id in enqueue response"); + expect($content)->toContain("requirePermission('add_collected_invoice_economic')"); + expect($content)->toContain('send_as_is must be a boolean'); + expect($content)->toContain("in_array(\$normalized_send_as_is, ['true', '1'], true)"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php new file mode 100644 index 00000000..1cf753c9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicTransferQueueSchemaBootstrapTest.php @@ -0,0 +1,27 @@ +not->toBeFalse(); + expect($content)->toContain('CREATE TABLE IF NOT EXISTS economic_transfer_queue_jobs'); + expect($content)->toContain("status VARCHAR(32) NOT NULL DEFAULT 'QUEUED'"); + expect($content)->toContain('progress_percent TINYINT UNSIGNED NOT NULL DEFAULT 0'); + expect($content)->toContain('error_message TEXT NULL'); + expect($content)->toContain('payload_json JSON NOT NULL'); + expect($content)->toContain('result_json JSON NULL'); +}); + +it('provides queue processor class constants and processing entrypoint', function (): void { + $content = file_get_contents(app_path('classes/economic_transfer_queue.php')); + + expect($content)->not->toBeFalse(); + expect($content)->toContain('public const STATUS_QUEUED'); + expect($content)->toContain('public const STATUS_PROCESSING'); + expect($content)->toContain('public const STATUS_COMPLETED'); + expect($content)->toContain('public const STATUS_FAILED'); + expect($content)->toContain('public const TYPE_ORDER_DRAFT_EXPORT'); + expect($content)->toContain('public const TYPE_ORDER_INVOICE_EXPORT'); + expect($content)->toContain('public const TYPE_COLLECTED_INVOICE_EXPORT'); + expect($content)->toContain('public function processPending(int $limit = 5): array'); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersRegistrationDateRangeQueryTest.php b/services/nginx/app/tests/Unit/Orders/OrdersRegistrationDateRangeQueryTest.php new file mode 100644 index 00000000..986ca6f9 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersRegistrationDateRangeQueryTest.php @@ -0,0 +1,127 @@ +> */ + private array $rows; + + /** + * @param array> $rows + */ + public function __construct(array $rows) + { + $this->rows = array_values($rows); + $this->num_rows = count($this->rows); + } + + /** + * @return array|null + */ + public function fetch_assoc(): ?array + { + if ($this->rows === []) { + return null; + } + + return array_shift($this->rows); + } +} + +final class OrdersRegistrationDateRangeDbStub +{ + public string $lastQuery = ''; + public int $queryCalls = 0; + + public function escape_string(string $value): string + { + return addslashes($value); + } + + public function query(string $sql): OrdersRegistrationDateRangeDbResultStub + { + $this->queryCalls++; + $this->lastQuery = $sql; + return new OrdersRegistrationDateRangeDbResultStub([]); + } +} + +it('applies created_at bounds directly in SQL when filtering orders by registration number', function (): void { + $dbStub = new OrdersRegistrationDateRangeDbStub(); + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $hadDb ? $GLOBALS['db'] : null; + $GLOBALS['db'] = $dbStub; + + try { + $orders = (new orders_o())->getOrdersWithRegistrationNumberInDateRange( + ' ec21235 ', + '2025-03-01 00:00:00', + '2025-04-30 23:59:59' + ); + + expect($orders)->toBe([]); + expect($dbStub->queryCalls)->toBe(1); + expect($dbStub->lastQuery)->toContain("UPPER(TRIM(reg_1)) = 'EC21235'"); + expect($dbStub->lastQuery)->toContain("UPPER(TRIM(reg_2)) = 'EC21235'"); + expect($dbStub->lastQuery)->toContain("UPPER(TRIM(reg_3)) = 'EC21235'"); + expect($dbStub->lastQuery)->toContain("created_at BETWEEN '2025-03-01 00:00:00' AND '2025-04-30 23:59:59'"); + expect($dbStub->lastQuery)->toContain('AND deleted_at IS NULL'); + expect($dbStub->lastQuery)->not->toContain('SELECT id, created_at'); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); + +it('rejects an inverted date range for registration lookups', function (): void { + $dbStub = new OrdersRegistrationDateRangeDbStub(); + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $hadDb ? $GLOBALS['db'] : null; + $GLOBALS['db'] = $dbStub; + + try { + (new orders_o())->getOrdersWithRegistrationNumberInDateRange( + 'EC21235', + '2025-04-30 23:59:59', + '2025-03-01 00:00:00' + ); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +})->throws(\Exception::class, 'The start date cannot be after the end date'); + +it('returns early when registration number is blank', function (): void { + $dbStub = new OrdersRegistrationDateRangeDbStub(); + $hadDb = array_key_exists('db', $GLOBALS); + $previousDb = $hadDb ? $GLOBALS['db'] : null; + $GLOBALS['db'] = $dbStub; + + try { + $orders = (new orders_o())->getOrdersWithRegistrationNumberInDateRange( + ' ', + '2025-03-01 00:00:00', + '2025-04-30 23:59:59' + ); + + expect($orders)->toBe([]); + expect($dbStub->queryCalls)->toBe(0); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $previousDb; + } else { + unset($GLOBALS['db']); + } + } +}); diff --git a/services/nginx/app/tests/Unit/Router/AutoloadRedisCacheValidationTest.php b/services/nginx/app/tests/Unit/Router/AutoloadRedisCacheValidationTest.php new file mode 100644 index 00000000..9c084e75 --- /dev/null +++ b/services/nginx/app/tests/Unit/Router/AutoloadRedisCacheValidationTest.php @@ -0,0 +1,11 @@ +not->toBeFalse(); + expect($content)->toContain('$cache_key = \'autoload:\' . $class;'); + expect($content)->toContain('if (is_string($cached) && $cached !== \'\' && is_file($cached)) {'); + expect($content)->toContain('if ($is_loaded($class)) {'); + expect($content)->toContain('redis->delete($cache_key);'); +});