diff --git a/openapi.yaml b/openapi.yaml index 027a8583..9a1f2f19 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -7465,7 +7465,8 @@ paths: description: Invoicing periods retrieved successfully content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/InvoicingPeriodResponseEnvelope' /superuser/invoicing/period/distribution/fixed-pricing: get: @@ -10916,23 +10917,89 @@ paths: get: tags: - Attachments - summary: Download order attachment - description: Download a specific order attachment + summary: Get order attachment download link + description: Return a legacy HTTPS download link for a specific order attachment operationId: downloadOrderAttachment parameters: - - name: id + - name: order_id in: query required: true schema: type: integer + minimum: 1 + - name: attachment_id + in: query + required: true + schema: + type: integer + minimum: 1 responses: '200': - description: Attachment downloaded successfully + description: Attachment download link resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/OrderAttachmentDownloadLinkResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '502': + description: Attachment storage is unavailable + + /orders/attachments/content: + get: + tags: + - Attachments + summary: Stream authenticated order attachment content + description: Streams an attachment after validating order scope and attachment ownership + operationId: streamOrderAttachmentContent + parameters: + - name: order_id + in: query + required: true + schema: + type: integer + minimum: 1 + - name: attachment_id + in: query + required: true + schema: + type: integer + minimum: 1 + - name: disposition + in: query + required: false + schema: + type: string + enum: [inline, attachment] + default: inline + responses: + '200': + description: Attachment content streamed successfully + headers: + Content-Disposition: + schema: + type: string content: application/octet-stream: schema: type: string format: binary + image/*: + schema: + type: string + format: binary + application/pdf: + 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' } + '502': + description: Attachment storage is unavailable # Forms Endpoints /form: @@ -17737,6 +17804,142 @@ components: - meta - includes + OrderAttachmentDownloadLinkResponse: + type: object + required: [success, data, meta, includes] + properties: + success: + type: boolean + enum: [true] + data: + type: object + required: [download_link] + properties: + download_link: + type: string + format: uri + pattern: '^https://' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + InvoicingPeriodResponseEnvelope: + type: object + required: [success, data, meta, includes] + properties: + success: + type: boolean + enum: [true] + data: + $ref: '#/components/schemas/InvoicingPeriodData' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + InvoicingPeriodData: + type: object + required: [dateFrom, dateTo, types] + properties: + dateFrom: + type: string + format: date + dateTo: + type: string + format: date + types: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/InvoicingPeriodCustomer' + + InvoicingPeriodCustomer: + type: object + required: [customer_number, customer_name, transactions, invoice_collections] + additionalProperties: true + properties: + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingPeriodTransaction' + invoice_collections: + type: array + items: + $ref: '#/components/schemas/InvoicingPeriodInvoiceCollection' + + InvoicingPeriodTransaction: + type: object + required: [id, booked, invoice_state] + additionalProperties: true + properties: + id: + type: integer + invoice_collection_id: + type: integer + nullable: true + booked: + type: boolean + invoice_state: + type: string + enum: [open, closed, economic_draft, economic_booked] + completed_at: + type: string + format: date-time + nullable: true + amount: + type: number + format: float + + InvoicingPeriodInvoiceCollection: + type: object + required: + - id + - invoice_collection_id + - customer_number + - state + - order_ids + - order_count + - total_net_amount + additionalProperties: true + properties: + id: + type: integer + invoice_collection_id: + type: integer + customer_number: + type: integer + name: + type: string + external_id: + type: string + nullable: true + booked_invoice_id: + type: integer + nullable: true + state: + type: string + enum: [open, closed, economic_draft, economic_booked] + order_ids: + type: array + items: + type: integer + order_count: + type: integer + minimum: 0 + total_net_amount: + type: number + format: float + CollectedInvoiceEconomicCompareResponse: type: object description: Result of comparing a collected invoice with its E-conomic counterpart @@ -17827,6 +18030,7 @@ components: type: integer economic: type: object + required: [draft_id, booked_id, state, available_pdf_type] properties: draft_id: type: integer @@ -17834,6 +18038,15 @@ components: booked_id: type: integer nullable: true + state: + type: string + enum: [draft, booked, none] + description: Current authoritative e-conomic target state + available_pdf_type: + type: string + enum: [draft, booked] + nullable: true + description: PDF target that callers should request customer: $ref: '#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary' internal: diff --git a/services/nginx/app/classes/attachment_store.php b/services/nginx/app/classes/attachment_store.php index aa9fe4e2..803affe0 100644 --- a/services/nginx/app/classes/attachment_store.php +++ b/services/nginx/app/classes/attachment_store.php @@ -37,8 +37,21 @@ class attachment_store implements minio_uploads_i */ public function isValidFilePath(string $filePath): bool { - // Check if the file path is valid - return preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) === 1; + if ( + $filePath === '' + || str_starts_with($filePath, '/') + || preg_match('/^[a-zA-Z0-9_\-\/.]+$/', $filePath) !== 1 + ) { + return false; + } + + foreach (explode('/', $filePath) as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..') { + return false; + } + } + + return true; } /** @@ -82,7 +95,10 @@ class attachment_store implements minio_uploads_i { $host = 'https://api.truckwash.io'; + $this->requireValidFilePath($fileName); + $encodedPath = implode('/', array_map('rawurlencode', explode('/', $fileName))); + // Generate a direct download URL for the given file name - return $host . '/files/' . $fileName; + return $host . '/files/' . $encodedPath; } -} \ No newline at end of file +} diff --git a/services/nginx/app/classes/customer_product_rule_service.php b/services/nginx/app/classes/customer_product_rule_service.php index c54159ec..1e0da1e8 100644 --- a/services/nginx/app/classes/customer_product_rule_service.php +++ b/services/nginx/app/classes/customer_product_rule_service.php @@ -10,9 +10,10 @@ class customer_product_rule_service { public const BLOCK_MESSAGE = 'This product is not allowed for the selected customer'; - private const ADDON_CATEGORY_ID = 4; + public const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID = 8; private const TANK_CLEANING_CATEGORY_ID = 5; private const SPOT_FREE_PRODUCT_IDS = [23, 24]; + private const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_NAMES = ['tillægsydelser', 'tillaegsydelser']; /** * @return array{rule:string,message:string}|null @@ -40,7 +41,11 @@ class customer_product_rule_service $isTankCleaningProduct = $this->isTankCleaningProduct($categoryId, $searchableProduct); if ($customer->doesUserHaveAttribute('restrictAdditionalServices') - && $this->isAdditionalServiceProduct($orderId, $relatedItemId, $categoryId, $searchableProduct)) { + && self::isStandaloneAdditionalServiceRow([ + 'related_item_id' => $relatedItemId, + 'product_category' => $categoryId, + 'category_name' => $categoryName, + ])) { return $this->violation('restrictAdditionalServices'); } @@ -76,21 +81,19 @@ class customer_product_rule_service ]; } - private function isAdditionalServiceProduct(int $orderId, ?int $relatedItemId, int $categoryId, string $searchableProduct): bool + public static function isStandaloneAdditionalServiceRow(array $row): bool { - if ($relatedItemId !== null && $relatedItemId > 0) { + if ((int)($row['related_item_id'] ?? 0) > 0) { + return false; + } + + $categoryId = (int)($row['product_category'] ?? $row['category'] ?? $row['category_id'] ?? 0); + if ($categoryId === self::STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID) { return true; } - if ($categoryId === self::ADDON_CATEGORY_ID) { - return true; - } - - if ($this->containsAny($searchableProduct, ['add-on', 'add on', 'addon', 'tilvalg'])) { - return true; - } - - return $this->countStandaloneOrderItems($orderId) > 0; + $categoryName = mb_strtolower(trim((string)($row['category_name'] ?? $row['categoryName'] ?? ''))); + return in_array($categoryName, self::STANDALONE_ADDITIONAL_SERVICE_CATEGORY_NAMES, true); } private function isTankCleaningProduct(int $categoryId, string $searchableProduct): bool @@ -147,22 +150,4 @@ class customer_product_rule_service return strtolower((string)($row['name'] ?? '')); } - private function countStandaloneOrderItems(int $orderId): int - { - global $db; - - $result = $db->query( - 'SELECT COUNT(*) AS item_count - FROM order_items - WHERE order_id = ' . $orderId . ' - AND deleted_at IS NULL - AND (related_item_id IS NULL OR related_item_id = 0)' - ); - if (!$result) { - return 0; - } - - $row = $result->fetch_assoc(); - return (int)($row['item_count'] ?? 0); - } } diff --git a/services/nginx/app/classes/invoice_period_flag_service.php b/services/nginx/app/classes/invoice_period_flag_service.php index 13a7c343..152e7769 100644 --- a/services/nginx/app/classes/invoice_period_flag_service.php +++ b/services/nginx/app/classes/invoice_period_flag_service.php @@ -902,7 +902,7 @@ class invoice_period_flag_service $isTankCleaningProduct = $this->rowIsTankCleaningProduct($row); if ($this->hasAttribute($attributes, $customerNumber, 'restrictAdditionalServices') - && (int)($row['related_item_id'] ?? 0) > 0 + && customer_product_rule_service::isStandaloneAdditionalServiceRow($row) && (int)($row['item_price'] ?? 0) > 0) { $flags[] = $this->automaticFlag( 'customer_rule_restrict_addon_services', diff --git a/services/nginx/app/classes/pdf_store.php b/services/nginx/app/classes/pdf_store.php index fe3eaf1b..7fffca9e 100644 --- a/services/nginx/app/classes/pdf_store.php +++ b/services/nginx/app/classes/pdf_store.php @@ -62,7 +62,7 @@ class pdf_store implements minio_pdfs_i public function isFileInStore(string $file): bool { - return self::getS3Client()->doesObjectExist(self::getBucket(), $file); + return self::doesObjectExist($file); } /** @@ -76,4 +76,4 @@ class pdf_store implements minio_pdfs_i $file_path ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/order_items_o.php b/services/nginx/app/objects/order_items_o.php index fa2ee529..1838bb47 100644 --- a/services/nginx/app/objects/order_items_o.php +++ b/services/nginx/app/objects/order_items_o.php @@ -6,6 +6,7 @@ use classes\db; use classes\customer_order_product_policy; use classes\object_property; use Exception; +use RuntimeException; use traits\db_object_t; class order_items_o extends db @@ -90,10 +91,42 @@ class order_items_o extends db $this->include_in_invoice = new object_property($this->table, $this->id, 'include_in_invoice', 'bool', false); } + private static function validatedRelatedItemId(int $orderId, mixed $relatedItemId): ?int + { + if ($relatedItemId === null || $relatedItemId === '' || (int)$relatedItemId === 0) { + return null; + } + + $normalizedRelatedItemId = (int)$relatedItemId; + if ($normalizedRelatedItemId < 1) { + throw new RuntimeException('Related item ID must be a positive integer'); + } + + global $db; + $result = $db->query( + "SELECT order_id + FROM order_items + WHERE id = {$normalizedRelatedItemId} + AND (deleted_at IS NULL OR deleted_at = '') + LIMIT 1" + ); + if (!$result || $result->num_rows < 1) { + throw new RuntimeException('Related order item not found'); + } + + $row = $result->fetch_assoc(); + if ((int)($row['order_id'] ?? 0) !== $orderId) { + throw new RuntimeException('Related order item must belong to the same order'); + } + + return $normalizedRelatedItemId; + } + public function add(int $order_id, int $product_id, string $reference, string $notes, int $cashier_id, int $price, int $quantity, $related_item_id = null): void { global $db, $response; try { + $related_item_id = self::validatedRelatedItemId($order_id, $related_item_id); customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id); // Avoid SQL injection $reference = $db->escape_string($reference); @@ -169,6 +202,7 @@ class order_items_o extends db try { // Get the order $order = (new orders_o())->getOrderById($order_id); + $related_item_id = self::validatedRelatedItemId($order_id, $related_item_id); customer_order_product_policy::assertOrderAllowsProduct($order_id, $product_id); // Get the product price $product = (new products_o())->getProductById($product_id); diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 9efb4809..05816ada 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -1259,6 +1259,7 @@ class orders_o extends db o.reg_1, o.reg_2, o.reg_3, + o.completed_at, o.invoice_collection_id, CASE WHEN o.include_in_invoice IS NOT NULL THEN o.include_in_invoice @@ -1299,6 +1300,7 @@ class orders_o extends db o.reg_1, o.reg_2, o.reg_3, + o.completed_at, o.invoice_collection_id, o.include_in_invoice, department_flags.exclude_from_invoicing @@ -1329,6 +1331,7 @@ class orders_o extends db 'reg_1' => (string)($row['reg_1'] ?? ''), 'reg_2' => (string)($row['reg_2'] ?? ''), 'reg_3' => (string)($row['reg_3'] ?? ''), + 'completed_at' => !empty($row['completed_at']) ? (string)$row['completed_at'] : null, 'excluded' => (int)($row['include_in_invoice_effective'] ?? 1) !== 1, 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, 'queue_status' => null, diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 35b31e9e..99563c28 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -7893,7 +7893,8 @@ paths: description: Invoicing periods retrieved successfully content: application/json: - schema: {} + schema: + $ref: '#/components/schemas/InvoicingPeriodResponseEnvelope' /superuser/invoicing/period/distribution/fixed-pricing: get: @@ -11587,23 +11588,89 @@ paths: get: tags: - Attachments - summary: Download order attachment - description: Download a specific order attachment + summary: Get order attachment download link + description: Return a legacy HTTPS download link for a specific order attachment operationId: downloadOrderAttachment parameters: - - name: id + - name: order_id in: query required: true schema: type: integer + minimum: 1 + - name: attachment_id + in: query + required: true + schema: + type: integer + minimum: 1 responses: '200': - description: Attachment downloaded successfully + description: Attachment download link resolved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/OrderAttachmentDownloadLinkResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '502': + description: Attachment storage is unavailable + + /orders/attachments/content: + get: + tags: + - Attachments + summary: Stream authenticated order attachment content + description: Streams an attachment after validating order scope and attachment ownership + operationId: streamOrderAttachmentContent + parameters: + - name: order_id + in: query + required: true + schema: + type: integer + minimum: 1 + - name: attachment_id + in: query + required: true + schema: + type: integer + minimum: 1 + - name: disposition + in: query + required: false + schema: + type: string + enum: [inline, attachment] + default: inline + responses: + '200': + description: Attachment content streamed successfully + headers: + Content-Disposition: + schema: + type: string content: application/octet-stream: schema: type: string format: binary + image/*: + schema: + type: string + format: binary + application/pdf: + 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' } + '502': + description: Attachment storage is unavailable # Forms Endpoints /form: @@ -19635,6 +19702,142 @@ components: - meta - includes + OrderAttachmentDownloadLinkResponse: + type: object + required: [success, data, meta, includes] + properties: + success: + type: boolean + enum: [true] + data: + type: object + required: [download_link] + properties: + download_link: + type: string + format: uri + pattern: '^https://' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + InvoicingPeriodResponseEnvelope: + type: object + required: [success, data, meta, includes] + properties: + success: + type: boolean + enum: [true] + data: + $ref: '#/components/schemas/InvoicingPeriodData' + meta: + type: object + additionalProperties: true + includes: + type: object + additionalProperties: true + + InvoicingPeriodData: + type: object + required: [dateFrom, dateTo, types] + properties: + dateFrom: + type: string + format: date + dateTo: + type: string + format: date + types: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/InvoicingPeriodCustomer' + + InvoicingPeriodCustomer: + type: object + required: [customer_number, customer_name, transactions, invoice_collections] + additionalProperties: true + properties: + customer_number: + type: integer + customer_name: + type: string + transactions: + type: array + items: + $ref: '#/components/schemas/InvoicingPeriodTransaction' + invoice_collections: + type: array + items: + $ref: '#/components/schemas/InvoicingPeriodInvoiceCollection' + + InvoicingPeriodTransaction: + type: object + required: [id, booked, invoice_state] + additionalProperties: true + properties: + id: + type: integer + invoice_collection_id: + type: integer + nullable: true + booked: + type: boolean + invoice_state: + type: string + enum: [open, closed, economic_draft, economic_booked] + completed_at: + type: string + format: date-time + nullable: true + amount: + type: number + format: float + + InvoicingPeriodInvoiceCollection: + type: object + required: + - id + - invoice_collection_id + - customer_number + - state + - order_ids + - order_count + - total_net_amount + additionalProperties: true + properties: + id: + type: integer + invoice_collection_id: + type: integer + customer_number: + type: integer + name: + type: string + external_id: + type: string + nullable: true + booked_invoice_id: + type: integer + nullable: true + state: + type: string + enum: [open, closed, economic_draft, economic_booked] + order_ids: + type: array + items: + type: integer + order_count: + type: integer + minimum: 0 + total_net_amount: + type: number + format: float + CollectedInvoiceEconomicCompareResponse: type: object description: Result of comparing a collected invoice with its E-conomic counterpart @@ -19725,6 +19928,7 @@ components: type: integer economic: type: object + required: [draft_id, booked_id, state, available_pdf_type] properties: draft_id: type: integer @@ -19732,6 +19936,15 @@ components: booked_id: type: integer nullable: true + state: + type: string + enum: [draft, booked, none] + description: Current authoritative e-conomic target state + available_pdf_type: + type: string + enum: [draft, booked] + nullable: true + description: PDF target that callers should request customer: $ref: '#/components/schemas/CollectedInvoiceEconomicV2CustomerSummary' internal: diff --git a/services/nginx/app/routes/InvoicingPeriodRoute.php b/services/nginx/app/routes/InvoicingPeriodRoute.php index 39d5eed0..490b1b9a 100644 --- a/services/nginx/app/routes/InvoicingPeriodRoute.php +++ b/services/nginx/app/routes/InvoicingPeriodRoute.php @@ -1890,6 +1890,10 @@ class InvoicingPeriodRoute $draftOverlay['by_collection_id'] ?? [], $draftOverlay['by_customer_number'] ?? [], ); + $invoiceCollectionMetadata = self::debugGetTime(function () use ($types) { + return self::getPeriodInvoiceCollectionMetadata($types); + }, 'invoice_collection_metadata'); + $types = self::applyPeriodInvoiceStateOverlayToTypes($types, $invoiceCollectionMetadata); if ($includeInvoicePeriodFlags) { $types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) { return (new invoice_period_flag_service())->applyFlagsToPeriodTypes( @@ -2029,6 +2033,7 @@ class InvoicingPeriodRoute 'meta' => $meta ?? [], 'queue' => self::getDefaultQueueSummary(), 'draft' => self::getDefaultDraftSummary(), + 'invoice_collections' => [], ]; } @@ -2046,11 +2051,15 @@ class InvoicingPeriodRoute $departmentId = (int)$transaction->department_id->value(); $invoiceCollectionId = (int)$transaction->invoice_collection_id->value(); + $booked = self::isTransactionBookedFromLocalState($transaction); + $completedAt = !empty($transaction->completed_at->value()) + ? (string)$transaction->completed_at->value() + : null; return [ 'id' => $transaction->id, 'date' => $transaction->created_at->value(), 'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier - 'booked' => self::isTransactionBookedFromLocalState($transaction), + 'booked' => $booked, 'department_id' => $departmentId, 'customer_number' => (int)$transaction->customer_id->value(), 'reference' => (string)$transaction->reference->value(), @@ -2059,8 +2068,10 @@ class InvoicingPeriodRoute 'reg_1' => (string)$transaction->reg_1->value(), 'reg_2' => (string)$transaction->reg_2->value(), 'reg_3' => (string)$transaction->reg_3->value(), + 'completed_at' => $completedAt, 'excluded' => !$transaction->isIncludedInInvoicing(), 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, + 'invoice_state' => self::periodOrderState($booked, $completedAt), 'queue_status' => null, 'queue_job_id' => null, ]; @@ -2082,8 +2093,13 @@ class InvoicingPeriodRoute 'reg_1' => (string)($transaction['reg_1'] ?? ''), 'reg_2' => (string)($transaction['reg_2'] ?? ''), 'reg_3' => (string)($transaction['reg_3'] ?? ''), + 'completed_at' => !empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null, 'excluded' => (bool)($transaction['excluded'] ?? ((int)($transaction['include_in_invoice_effective'] ?? 1) !== 1)), 'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null, + 'invoice_state' => self::periodOrderState( + (bool)($transaction['booked'] ?? false), + !empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null + ), 'queue_status' => $transaction['queue_status'] ?? null, 'queue_job_id' => $transaction['queue_job_id'] ?? null, ]; @@ -2121,6 +2137,15 @@ class InvoicingPeriodRoute return self::$periodOrderBookedCache[$orderId] = !empty($row['invoice_id'] ?? null); } + private static function periodOrderState(bool $booked, ?string $completedAt): string + { + if ($booked) { + return 'economic_booked'; + } + + return !empty($completedAt) ? 'closed' : 'open'; + } + private static function checkRequiresAction(array $parsed_transactions, bool $requires_action): bool { // If requires_action is already set to true, return true @@ -2625,6 +2650,199 @@ class InvoicingPeriodRoute return $customer; } + /** + * Fetch the collected-invoice rows referenced by the complete period payload in one local query. + * No e-conomic calls are allowed from the period endpoint. + * + * @return array> Rows keyed by collected invoice ID. + */ + private static function getPeriodInvoiceCollectionMetadata(array $types): array + { + global $db; + + $invoiceCollectionIds = []; + foreach ($types as $customers) { + if (!is_array($customers)) { + continue; + } + + foreach ($customers as $customer) { + if (!is_array($customer)) { + continue; + } + + foreach (($customer['transactions'] ?? []) as $transaction) { + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + } + + foreach (['queue', 'draft'] as $summaryKey) { + foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) { + $invoiceCollectionId = (int)$invoiceCollectionId; + if ($invoiceCollectionId > 0) { + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + } + } + } + } + + if (empty($invoiceCollectionIds)) { + return []; + } + + $sql = 'SELECT id, customer_number, name, notes, processor, external_id, booked_invoice_id, ' + . 'po_number, error_message, closed_at, created_at, updated_at ' + . 'FROM collected_order_invoices ' + . 'WHERE id IN (' . implode(',', array_map('intval', array_values($invoiceCollectionIds))) . ') ' + . 'ORDER BY id'; + + try { + $result = $db->query($sql); + if (!$result) { + return []; + } + + $metadata = []; + while ($row = $result->fetch_assoc()) { + $invoiceCollectionId = (int)($row['id'] ?? 0); + if ($invoiceCollectionId < 1) { + continue; + } + + $row['id'] = $invoiceCollectionId; + $row['invoice_collection_id'] = $invoiceCollectionId; + $row['customer_number'] = (int)($row['customer_number'] ?? 0); + $row['processor'] = (int)($row['processor'] ?? 0); + $row['booked_invoice_id'] = !empty($row['booked_invoice_id']) + ? (int)$row['booked_invoice_id'] + : null; + $row['state'] = self::periodInvoiceCollectionState($row); + $metadata[$invoiceCollectionId] = $row; + } + + return $metadata; + } catch (\Throwable) { + return []; + } + } + + private static function periodInvoiceCollectionState(array $invoiceCollection): string + { + if (!empty($invoiceCollection['booked_invoice_id'])) { + return 'economic_booked'; + } + + if (!defined('\\objects\\ECONOMIC_PROCESSOR')) { + class_exists(collected_order_invoices_o::class); + } + $economicProcessor = defined('\\objects\\ECONOMIC_PROCESSOR') + ? (int)constant('\\objects\\ECONOMIC_PROCESSOR') + : 1; + if ( + (int)($invoiceCollection['processor'] ?? 0) === $economicProcessor + && trim((string)($invoiceCollection['external_id'] ?? '')) !== '' + && trim((string)($invoiceCollection['error_message'] ?? '')) === '' + ) { + return 'economic_draft'; + } + + return !empty($invoiceCollection['closed_at']) ? 'closed' : 'open'; + } + + private static function applyPeriodInvoiceStateOverlayToTypes(array $types, array $invoiceCollectionsById): array + { + foreach ($types as $type => $customers) { + if (!is_array($customers)) { + continue; + } + + $types[$type] = array_map(static function ($customer) use ($invoiceCollectionsById) { + if (!is_array($customer)) { + return $customer; + } + + return self::applyPeriodInvoiceStateOverlayToCustomer($customer, $invoiceCollectionsById); + }, $customers); + } + + return $types; + } + + private static function applyPeriodInvoiceStateOverlayToCustomer(array $customer, array $invoiceCollectionsById): array + { + $customerNumber = (int)($customer['customer_number'] ?? 0); + $collectionStats = []; + $invoiceCollectionIds = []; + $transactions = []; + + foreach (($customer['transactions'] ?? []) as $transaction) { + if (!is_array($transaction)) { + continue; + } + + $invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0); + $invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId] ?? null; + $canUseCollection = is_array($invoiceCollection) + && (int)($invoiceCollection['customer_number'] ?? 0) === $customerNumber; + + if ($canUseCollection) { + $transaction['invoice_state'] = (string)$invoiceCollection['state']; + $transaction['booked'] = $transaction['invoice_state'] === 'economic_booked'; + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + $collectionStats[$invoiceCollectionId] = $collectionStats[$invoiceCollectionId] ?? [ + 'order_ids' => [], + 'total_net_amount' => 0.0, + ]; + $orderId = (int)($transaction['id'] ?? 0); + if ($orderId > 0) { + $collectionStats[$invoiceCollectionId]['order_ids'][$orderId] = $orderId; + } + $collectionStats[$invoiceCollectionId]['total_net_amount'] += (float)($transaction['amount'] ?? 0); + } else { + $transaction['invoice_state'] = (string)($transaction['invoice_state'] ?? self::periodOrderState( + (bool)($transaction['booked'] ?? false), + !empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null + )); + } + + $transactions[] = $transaction; + } + + foreach (['queue', 'draft'] as $summaryKey) { + foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) { + $invoiceCollectionId = (int)$invoiceCollectionId; + if ( + $invoiceCollectionId > 0 + && isset($invoiceCollectionsById[$invoiceCollectionId]) + && (int)($invoiceCollectionsById[$invoiceCollectionId]['customer_number'] ?? 0) === $customerNumber + ) { + $invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId; + } + } + } + + $invoiceCollections = []; + foreach ($invoiceCollectionIds as $invoiceCollectionId) { + $invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId]; + $stats = $collectionStats[$invoiceCollectionId] ?? [ + 'order_ids' => [], + 'total_net_amount' => 0.0, + ]; + $orderIds = array_values($stats['order_ids']); + $invoiceCollection['order_ids'] = $orderIds; + $invoiceCollection['order_count'] = count($orderIds); + $invoiceCollection['total_net_amount'] = (float)$stats['total_net_amount']; + $invoiceCollections[] = $invoiceCollection; + } + + $customer['transactions'] = $transactions; + $customer['invoice_collections'] = $invoiceCollections; + return $customer; + } + private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool { $meta = $customer['meta'] ?? []; diff --git a/services/nginx/app/routes/orderInvoicesRoute.php b/services/nginx/app/routes/orderInvoicesRoute.php index 9bc6e3b4..028b0029 100644 --- a/services/nginx/app/routes/orderInvoicesRoute.php +++ b/services/nginx/app/routes/orderInvoicesRoute.php @@ -2023,16 +2023,31 @@ class orderInvoicesRoute $economic = new economic(); - try { - $draft_id = $invoice->getInvoiceDraftId(); - } catch (Exception $e) { - $warnings[] = 'Draft id unavailable: ' . $e->getMessage(); - } + $persisted_booked_id = (int)$invoice->booked_invoice_id->value(); + if ($persisted_booked_id > 0) { + // A booked invoice replaces its draft in e-conomic. Avoid reporting that expected + // transition as a missing-draft warning. + $booked_id = $persisted_booked_id; + } else { + $draft_error = null; + try { + $draft_id = $invoice->getInvoiceDraftId(); + } catch (Exception $e) { + $draft_error = $e; + } - try { - $booked_id = $invoice->getInvoiceBookedId(); - } catch (Exception $e) { - $warnings[] = 'Booked id unavailable: ' . $e->getMessage(); + // Older rows may have an external ID but no persisted booked ID. Only probe the + // booked endpoint when the draft is gone, then persist through getInvoiceBookedId(). + if ($draft_id === null) { + try { + $booked_id = $invoice->getInvoiceBookedId(); + } catch (Exception $e) { + if ($draft_error !== null) { + $warnings[] = 'Draft id unavailable: ' . $draft_error->getMessage(); + } + $warnings[] = 'Booked id unavailable: ' . $e->getMessage(); + } + } } if ($draft_id !== null) { @@ -2093,6 +2108,11 @@ class orderInvoicesRoute ? economic_v2_line_normalizer::normalizeBookedInvoice($booked_raw) : null; + $economic_state = $booked_id !== null + ? 'booked' + : ($draft_id !== null ? 'draft' : 'none'); + $available_pdf_type = $economic_state === 'none' ? null : $economic_state; + return [ 'collected_invoice_id' => $collected_invoice_id, 'external_id' => (string)$invoice->external_id->value(), @@ -2102,6 +2122,8 @@ class orderInvoicesRoute 'economic' => [ 'draft_id' => $draft_id !== null ? (int)$draft_id : null, 'booked_id' => $booked_id !== null ? (int)$booked_id : null, + 'state' => $economic_state, + 'available_pdf_type' => $available_pdf_type, ], 'customer' => $customer, 'internal' => [ @@ -2134,26 +2156,45 @@ class orderInvoicesRoute $invoice->requireSelected(); $this->requireCollectedInvoiceContextAccess($invoice); + $resolved_type = $type; $economic_invoice_id = null; - try { - $economic_invoice_id = $type === 'draft' - ? $invoice->getInvoiceDraftId() - : $invoice->getInvoiceBookedId(); - } catch (Exception $e) { - $response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); + $persisted_booked_id = (int)$invoice->booked_invoice_id->value(); + + if ($persisted_booked_id > 0) { + $resolved_type = 'booked'; + $economic_invoice_id = $persisted_booked_id; + } else { + try { + $economic_invoice_id = $type === 'draft' + ? $invoice->getInvoiceDraftId() + : $invoice->getInvoiceBookedId(); + } catch (Exception $e) { + // A legacy booked row can have only the former draft external ID persisted. + // Fall back to the booked target when a requested draft no longer exists. + if ($type === 'draft') { + try { + $economic_invoice_id = $invoice->getInvoiceBookedId(); + $resolved_type = 'booked'; + } catch (Exception $bookedException) { + $response->error('No e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); + } + } else { + $response->error('No booked e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); + } + } } $economic_invoice_id = (int)$economic_invoice_id; if ($economic_invoice_id <= 0) { - $response->error('No ' . $type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); + $response->error('No ' . $resolved_type . ' e-conomic invoice found for collected invoice ' . $collected_invoice_id, 404); } $economic = new economic(); - $invoice_path_file = $type === 'draft' + $invoice_path_file = $resolved_type === 'draft' ? $economic->invoices->pdf->getDraft($economic_invoice_id) : $economic->invoices->pdf->getBooked($economic_invoice_id); - $store_key_id = 'economic_' . $type . '_' . $economic_invoice_id; + $store_key_id = 'economic_' . $resolved_type . '_' . $economic_invoice_id; $invoice_store = new invoice_store(); try { $invoice_store->uploadFile('invoice_' . $store_key_id . '.pdf', $invoice_path_file); @@ -2175,7 +2216,7 @@ class orderInvoicesRoute return [ 'collected_invoice_id' => $collected_invoice_id, - 'type' => $type, + 'type' => $resolved_type, 'economic_invoice_id' => $economic_invoice_id, 'url' => $invoice_store->getInvoiceDownloadUrl($store_key_id), ]; diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index 7625c5e4..37b07a48 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -9,6 +9,7 @@ use classes\authentication; use classes\economic; use classes\order_reference_suggestions_service; use classes\orders_input_normalizer; +use classes\pdf_store; use classes\response; use classes\stripe; use JetBrains\PhpStorm\NoReturn; @@ -334,68 +335,13 @@ class ordersRoute ); $this->get('/orders/attachments/download', function () { - // Require the user to be logged in global $response; - // Permissions (subuser-aware) - $permission_own = self::definePermission('download_order_attachments_own', subusers_permission_node_key::ORDERS_LIST); - $permission_other = self::definePermission('download_order_attachments'); - $has_permission_other = self::hasPermission($permission_other); - if (!$has_permission_other) { - self::requirePermission($permission_own); - } - // Get the user object - $user = (new authentication())->get_user(); - // Check if the request was successful - if ($user) { - // Get the order ID and attachment ID from the request - self::requireParameters([ - 'order_id', - 'attachment_id' - ]); - $order_id = self::getParameter('order_id'); - $attachment_id = self::getParameter('attachment_id'); - if (!is_numeric($order_id) || (int)$order_id < 1) { - $response->error('Invalid order ID', 400); - } - if (!is_numeric($attachment_id) || (int)$attachment_id < 1) { - $response->error('Invalid attachment ID', 400); - } - // Get the current order - $order = (new orders_o())->getOrderById((int)$order_id); - // Check if the order exists - if (!$order->exists()) { - $response->error('Order not found', 400); - } - // If operating under own-scope (classic user or subuser), ensure the order belongs to the effective customer context - if (!$has_permission_other) { - $effectiveCustomer = self::resolveEffectiveCustomerNumber(); - if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { - $response->forbidden([$permission_other->permission]); - } - } - // Get the attachment - $attachment = $order->getAttachment((int)$attachment_id); - if (!$attachment->exists()) { - $response->error('Attachment not found', 400); - } - // Create a download link - $attachment_store = new attachment_store(); - $attachments = new attachments(); - $attachment_formatted = $attachments->format($attachment); - $download_link = $attachment_store->generateDirectDownloadUrl( - $attachment_formatted->content->document - ); - // Log the incident - (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DOWNLOAD_ORDER_ATTACHMENT', 'Successfully downloaded an attachment for an order (Order ID: ' . $order_id . ', Attachment ID: ' . $attachment_id . ')'); - // Return the download link - $response->success(['download_link' => $download_link]); - - } else { - // Log the incident - (new logs_o())->add('orders', 'global', 1, 0, 'DOWNLOAD_ORDER_ATTACHMENT', 'No user found, or invalid session'); - // Return an error - $response->error('Invalid session', 400); - } + $context = $this->requireOrderAttachmentDownloadContext(); + $downloadLink = $context['store'] instanceof pdf_store + ? $context['store']->getPresignedUrl($context['object_key']) + : $context['store']->generateDirectDownloadUrl($context['object_key']); + $this->logOrderAttachmentDownload($context, 'DOWNLOAD_ORDER_ATTACHMENT'); + $response->success(['download_link' => $downloadLink]); }, [ 'download_order_attachments' => 'Download attachments for an order', @@ -403,6 +349,49 @@ class ordersRoute ] ); + $this->get('/orders/attachments/content', function () { + global $response; + + $context = $this->requireOrderAttachmentDownloadContext(); + $disposition = strtolower(trim((string)(self::getParameter('disposition') ?? 'inline'))); + if (!in_array($disposition, ['inline', 'attachment'], true)) { + $response->error('disposition must be either inline or attachment', 400); + } + + $temporaryPath = null; + try { + $temporaryPath = $context['store']->downloadToTemporaryFile($context['object_key']); + $mimeType = $this->detectAttachmentMimeType($temporaryPath); + $fileName = $this->sanitizeAttachmentDownloadFileName( + $context['file_name'], + $context['object_key'] + ); + $fileSize = filesize($temporaryPath); + if ($fileSize === false) { + throw new \RuntimeException('Unable to determine attachment size'); + } + + $this->logOrderAttachmentDownload($context, 'STREAM_ORDER_ATTACHMENT'); + header('Content-Type: ' . $mimeType); + header('Content-Disposition: ' . $disposition . '; filename="' . addcslashes($fileName, "\\\"") . '"; filename*=UTF-8\'\'' . rawurlencode($fileName)); + header('Content-Length: ' . $fileSize); + header('Cache-Control: private, no-store'); + header('X-Content-Type-Options: nosniff'); + http_response_code(200); + readfile($temporaryPath); + } finally { + if (is_string($temporaryPath) && file_exists($temporaryPath)) { + unlink($temporaryPath); + } + } + exit; + }, + [ + 'download_order_attachments' => 'Stream attachments for an order', + 'download_order_attachments_own' => 'Stream attachments for an order (Only for own orders). Subusers require node: ORDERS_LIST and X-Customer-Number header.' + ] + ); + $this->get('/orders/attachments', function () { // Require the user to be logged in global $response; @@ -1669,6 +1658,146 @@ class ordersRoute return $flags; } + /** + * @return array{user:object,order:orders_o,order_id:int,attachment_id:int,object_key:string,file_name:string,store:attachment_store|pdf_store} + */ + private function requireOrderAttachmentDownloadContext(): array + { + global $response; + + $permissionOwn = self::definePermission('download_order_attachments_own', subusers_permission_node_key::ORDERS_LIST); + $permissionOther = self::definePermission('download_order_attachments'); + $hasPermissionOther = self::hasPermission($permissionOther); + if (!$hasPermissionOther) { + self::requirePermission($permissionOwn); + } + + $user = (new authentication())->get_user(); + if (!$user) { + (new logs_o())->add('orders', 'global', 1, 0, 'DOWNLOAD_ORDER_ATTACHMENT', 'No user found, or invalid session'); + $response->error('Invalid session', 400); + } + + self::requireParameters(['order_id', 'attachment_id']); + $orderId = self::getParameter('order_id'); + $attachmentId = self::getParameter('attachment_id'); + if (!is_numeric($orderId) || (int)$orderId < 1) { + $response->error('Invalid order ID', 400); + } + if (!is_numeric($attachmentId) || (int)$attachmentId < 1) { + $response->error('Invalid attachment ID', 400); + } + $orderId = (int)$orderId; + $attachmentId = (int)$attachmentId; + + $order = (new orders_o())->getOrderById($orderId); + if (!$order->exists()) { + $response->error('Order not found', 404); + } + if (!$hasPermissionOther) { + $effectiveCustomer = self::resolveEffectiveCustomerNumber(); + if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { + $response->forbidden([$permissionOther->permission]); + } + } + + $attachment = $order->getAttachment($attachmentId); + $attachmentType = $attachment->exists() + ? trim((string)$attachment->object_type->value(), '`') + : ''; + if ( + !$attachment->exists() + || $attachmentType !== 'orders' + || (int)$attachment->object_id->value() !== $orderId + || !empty($attachment->deleted_at->value()) + ) { + $response->error('Attachment not found for order', 404); + } + + $formattedAttachment = (new attachments())->format($attachment); + $objectKey = trim((string)( + $formattedAttachment->content->document + ?? $formattedAttachment->content->image + ?? '' + )); + if ($objectKey === '') { + $response->error('Attachment has no stored file', 404); + } + + $attachmentStore = new attachment_store(); + if (!$attachmentStore->isValidFilePath($objectKey)) { + $response->error('Attachment contains an invalid stored file path', 400); + } + $isWashCertificate = $formattedAttachment->isWashCertificate(); + $store = $isWashCertificate ? new pdf_store() : $attachmentStore; + try { + if (!$store->doesObjectExist($objectKey)) { + $response->error('Attachment file not found', 404); + } + } catch (\Throwable) { + $response->error('Attachment storage is unavailable', 502); + } + + $originalFileName = is_string($formattedAttachment->content->other) + ? $formattedAttachment->content->other + : ''; + if ($isWashCertificate) { + $originalFileName = 'wash_certificate.pdf'; + } + + return [ + 'user' => $user, + 'order' => $order, + 'order_id' => $orderId, + 'attachment_id' => $attachmentId, + 'object_key' => $objectKey, + 'file_name' => $originalFileName, + 'store' => $store, + ]; + } + + private function logOrderAttachmentDownload(array $context, string $action): void + { + (new logs_o())->add( + 'orders', + $context['order']->department_id->value(), + 1, + $context['user']->id, + $action, + 'Successfully accessed an attachment for an order (Order ID: ' + . $context['order_id'] + . ', Attachment ID: ' + . $context['attachment_id'] + . ')' + ); + } + + private function detectAttachmentMimeType(string $path): string + { + $mimeType = false; + if (class_exists(\finfo::class)) { + $mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($path); + } + if ((!is_string($mimeType) || $mimeType === '') && function_exists('mime_content_type')) { + $mimeType = mime_content_type($path); + } + + return is_string($mimeType) && preg_match('#^[a-z0-9.+-]+/[a-z0-9.+-]+$#i', $mimeType) === 1 + ? $mimeType + : 'application/octet-stream'; + } + + private function sanitizeAttachmentDownloadFileName(string $fileName, string $objectKey): string + { + $fileName = trim(str_replace(["\r", "\n", "\0"], '', basename($fileName))); + if ($fileName === '' || $fileName === '.' || $fileName === '..') { + $fileName = basename($objectKey); + } + + $fileName = preg_replace('/[\\x00-\\x1F\\x7F\\/\\\\]/u', '_', $fileName) ?? 'attachment'; + return trim($fileName) !== '' ? $fileName : 'attachment'; + } + /** * @param mixed $data * @param response $response diff --git a/services/nginx/app/tests/Api/OrderItemsApiTest.php b/services/nginx/app/tests/Api/OrderItemsApiTest.php index e02fc464..124c7b68 100644 --- a/services/nginx/app/tests/Api/OrderItemsApiTest.php +++ b/services/nginx/app/tests/Api/OrderItemsApiTest.php @@ -317,7 +317,7 @@ it('returns the extraordinary chemistry product with requires_note enabled', fun expect($response->data()['requires_note'] ?? null)->toBeTrue(); }); -it('blocks addon products added as standalone additional order items for customers restricted from additional services', function (): void { +it('blocks standalone category 8 products for customers restricted from additional services', function (): void { api_test_covers('POST /order/items', 'customer-rule-validation'); $fixture = create_order_item_rule_fixture(['restrictAdditionalServices']); @@ -326,8 +326,8 @@ it('blocks addon products added as standalone additional order items for custome 'price' => 200, ]); $addonProduct = api_fixtures()->createProduct([ - 'name' => 'Drying add-on', - 'category' => 4, + 'name' => 'Extra detergent', + 'category' => 8, 'price' => 50, ]); @@ -345,7 +345,7 @@ it('blocks addon products added as standalone additional order items for custome ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); }); -it('allows standalone additional order items when the customer is not restricted from additional services', function (): void { +it('allows standalone category 8 products when the customer is not restricted from additional services', function (): void { api_test_covers('POST /order/items', 'customer-rule-validation'); $fixture = create_order_item_rule_fixture(); @@ -354,8 +354,8 @@ it('allows standalone additional order items when the customer is not restricted 'price' => 200, ]); $addonProduct = api_fixtures()->createProduct([ - 'name' => 'Unrestricted add-on', - 'category' => 4, + 'name' => 'Unrestricted additional service', + 'category' => 8, 'price' => 50, ]); @@ -370,7 +370,7 @@ it('allows standalone additional order items when the customer is not restricted ->assertSuccess(); }); -it('blocks related addon order items for customers restricted from additional services', function (): void { +it('allows related addon order items for customers restricted from additional services', function (): void { api_test_covers('POST /order/items', 'customer-rule-validation'); $fixture = create_order_item_rule_fixture(['restrictAdditionalServices']); @@ -381,6 +381,7 @@ it('blocks related addon order items for customers restricted from additional se ]); $addonProduct = api_fixtures()->createProduct([ 'name' => 'Related extra brush', + 'category' => 8, 'price' => 35, ]); $primaryItem = api_fixtures()->createOrderItem([ @@ -392,6 +393,28 @@ it('blocks related addon order items for customers restricted from additional se post_order_item($fixture['order'], $addonProduct, $fixture['session']['headers'], [ 'related_item_id' => $primaryItem['id'], + ]) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); +}); + +it('still blocks related add-ons covered by a specific customer product rule', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(['restrictInteriorCleaning']); + $cashier = api_fixtures()->createUser(['display_name' => 'Specific Related Rule Cashier']); + $primaryProduct = api_fixtures()->createProduct(['name' => 'Primary truck wash', 'price' => 200]); + $interiorProduct = api_fixtures()->createProduct(['name' => 'Indvendig vask Forvogn', 'category' => 4, 'price' => 35]); + $primaryItem = api_fixtures()->createOrderItem([ + 'order_id' => $fixture['order']['id'], + 'product_id' => $primaryProduct['id'], + 'cashier_id' => $cashier['id'], + 'price' => 200, + ]); + + post_order_item($fixture['order'], $interiorProduct, $fixture['session']['headers'], [ + 'related_item_id' => $primaryItem['id'], ]) ->assertStatus(400) ->assertEnvelope() @@ -399,6 +422,109 @@ it('blocks related addon order items for customers restricted from additional se ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); }); +it('does not infer additional-service restrictions from category 4, product names, or existing order items', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(['restrictAdditionalServices']); + $primaryProduct = api_fixtures()->createProduct([ + 'name' => 'Primary wash', + 'category' => 4, + 'price' => 200, + ]); + $namedAddonProduct = api_fixtures()->createProduct([ + 'name' => 'Trailer add-on', + 'category' => 4, + 'price' => 35, + ]); + + post_order_item($fixture['order'], $primaryProduct, $fixture['session']['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + post_order_item($fixture['order'], $namedAddonProduct, $fixture['session']['headers']) + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); +}); + +it('uses the exact Tillægsydelser category name as the legacy additional-service fallback', function (): void { + api_test_covers('POST /order/items', 'customer-rule-validation'); + + $fixture = create_order_item_rule_fixture(['restrictAdditionalServices']); + $category = api_fixtures()->createCategory(['name' => 'Tillægsydelser']); + $product = api_fixtures()->createProduct([ + 'name' => 'Legacy additional service', + 'category' => $category['id'], + 'price' => 35, + ]); + + post_order_item($fixture['order'], $product, $fixture['session']['headers']) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage(\classes\customer_product_rule_service::BLOCK_MESSAGE); +}); + +it('validates that related order items exist, are active, and belong to the target order', function (): void { + api_test_covers('POST /order/items', 'related-item-validation'); + + $fixture = create_order_item_rule_fixture(); + $cashier = api_fixtures()->createUser(['display_name' => 'Related Item Cashier']); + $product = api_fixtures()->createProduct(['name' => 'Related item validation product', 'price' => 35]); + $otherOrder = api_fixtures()->createOrder([ + 'customer_id' => $fixture['customer']['customer_number'], + 'department_id' => $fixture['department']['id'], + 'cashier_id' => $cashier['id'], + ]); + $validParent = api_fixtures()->createOrderItem([ + 'order_id' => $fixture['order']['id'], + 'product_id' => $product['id'], + 'cashier_id' => $cashier['id'], + 'price' => 35, + ]); + $otherOrderParent = api_fixtures()->createOrderItem([ + 'order_id' => $otherOrder['id'], + 'product_id' => $product['id'], + 'cashier_id' => $cashier['id'], + 'price' => 35, + ]); + $deletedParent = api_fixtures()->createOrderItem([ + 'order_id' => $fixture['order']['id'], + 'product_id' => $product['id'], + 'cashier_id' => $cashier['id'], + 'price' => 35, + 'deleted_at' => '2026-07-15 12:00:00', + ]); + + foreach ([$deletedParent['id'], 999999999] as $missingParentId) { + post_order_item($fixture['order'], $product, $fixture['session']['headers'], [ + 'related_item_id' => $missingParentId, + ]) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Related order item not found'); + } + + post_order_item($fixture['order'], $product, $fixture['session']['headers'], [ + 'related_item_id' => $otherOrderParent['id'], + ]) + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Related order item must belong to the same order'); + + $response = post_order_item($fixture['order'], $product, $fixture['session']['headers'], [ + 'related_item_id' => $validParent['id'], + ]); + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + expect((int)($response->data()['related_item_id'] ?? 0))->toBe((int)$validParent['id']); +}); + it('blocks named restricted service products for the selected customer', function (string $attribute, array $productAttributes): void { api_test_covers('POST /order/items', 'customer-rule-validation'); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicBookedInvoiceResolutionTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicBookedInvoiceResolutionTest.php new file mode 100644 index 00000000..6da74ab8 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicBookedInvoiceResolutionTest.php @@ -0,0 +1,26 @@ +not->toBeFalse(); + $content = (string)$content; + + $persistedBookedPosition = strpos($content, '$persisted_booked_id = (int)$invoice->booked_invoice_id->value();'); + $draftLookupPosition = strpos($content, '$draft_id = $invoice->getInvoiceDraftId();', (int)$persistedBookedPosition); + + expect($persistedBookedPosition)->not->toBeFalse(); + expect($draftLookupPosition)->not->toBeFalse(); + expect($persistedBookedPosition)->toBeLessThan($draftLookupPosition); + expect($content)->toContain("'state' => \$economic_state"); + expect($content)->toContain("'available_pdf_type' => \$available_pdf_type"); +}); + +it('falls back from a missing draft to the booked pdf target', function (): void { + $content = file_get_contents(app_path('routes/orderInvoicesRoute.php')); + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain("\$resolved_type = 'booked';"); + expect($content)->toContain('$economic->invoices->pdf->getBooked($economic_invoice_id)'); + expect($content)->toContain("'type' => \$resolved_type"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php index 84c58800..d6a16ea0 100644 --- a/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicePeriodFlagServiceTest.php @@ -331,6 +331,63 @@ it('allows tank cleaning products for only tank cleaning customers', function () expect($restrictedTankCleaningFlags[0]['target_id'])->toBe(801); }); +it('flags only standalone Tillægsydelser items for the additional-services customer rule', function (): void { + $baseRow = [ + 'customer_number' => 424242, + 'customer_name' => 'Additional Services Customer', + 'order_id' => 61427, + 'invoice_collection_id' => 3091, + 'department_id' => 5, + 'item_price' => 100, + 'order_reference' => 'REF', + 'order_po' => 'PO', + 'reg_1' => 'AB12345', + ]; + $standaloneCategoryEight = $baseRow + [ + 'order_item_id' => 811, + 'product_id' => 91, + 'product_name' => 'Extra detergent', + 'product_category' => 8, + 'category_name' => 'Tillægsydelser', + 'related_item_id' => null, + ]; + $relatedCategoryEight = $baseRow + [ + 'order_item_id' => 812, + 'product_id' => 71, + 'product_name' => 'Interior rinse', + 'product_category' => 8, + 'category_name' => 'Tillægsydelser', + 'related_item_id' => 810, + ]; + $namedCategoryFourAddon = $baseRow + [ + 'order_item_id' => 813, + 'product_id' => 63, + 'product_name' => 'Trailer add-on', + 'product_category' => 4, + 'category_name' => 'Addons', + 'related_item_id' => null, + ]; + $legacyCategoryName = $baseRow + [ + 'order_item_id' => 814, + 'product_id' => 94, + 'product_name' => 'Legacy additional service', + 'product_category' => 18, + 'category_name' => 'TILLÆGSYDELSER', + 'related_item_id' => null, + ]; + + $flags = invoice_period_flag_service_invoke('detectCustomerRuleViolations', [ + [$standaloneCategoryEight, $relatedCategoryEight, $namedCategoryFourAddon, $legacyCategoryName], + [424242 => ['restrictAdditionalServices' => true]], + ]); + + expect(array_column($flags, 'definition_key'))->toBe([ + 'customer_rule_restrict_addon_services', + 'customer_rule_restrict_addon_services', + ]); + expect(array_column($flags, 'target_id'))->toBe([811, 814]); +}); + it('does not flag interior wash variants as historical primary product mismatches', function (): void { global $db; diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php new file mode 100644 index 00000000..852fbd80 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodBackendOpenApiTest.php @@ -0,0 +1,24 @@ +toBeFile(); + $content = file_get_contents($openApiFile); + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('/orders/attachments/content:'); + expect($content)->toContain('OrderAttachmentDownloadLinkResponse:'); + expect($content)->toContain('InvoicingPeriodResponseEnvelope:'); + expect($content)->toContain('InvoicingPeriodInvoiceCollection:'); + expect($content)->toContain('enum: [open, closed, economic_draft, economic_booked]'); + expect($content)->toContain('available_pdf_type:'); + expect($content)->toContain('enum: [draft, booked, none]'); + } +}); + diff --git a/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodInvoiceStateTest.php b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodInvoiceStateTest.php new file mode 100644 index 00000000..49af81b0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodInvoiceStateTest.php @@ -0,0 +1,166 @@ +getMethod($method); + return $target->invokeArgs(null, $args); +} + +final class InvoicingPeriodInvoiceStateFakeResult +{ + public int $num_rows; + + public function __construct(private array $rows) + { + $this->num_rows = count($rows); + } + + public function fetch_assoc(): ?array + { + return array_shift($this->rows); + } +} + +final class InvoicingPeriodInvoiceStateFakeDb +{ + public int $queryCount = 0; + public string $sql = ''; + + public function __construct(private array $rows) + { + } + + public function query(string $sql): InvoicingPeriodInvoiceStateFakeResult + { + $this->queryCount++; + $this->sql = $sql; + return new InvoicingPeriodInvoiceStateFakeResult($this->rows); + } +} + +it('applies canonical invoice collection state precedence', function (): void { + expect(invoicing_period_invoice_state_invoke('periodInvoiceCollectionState', [[ + 'booked_invoice_id' => 9001, + 'processor' => 1, + 'external_id' => 'draft-1', + 'closed_at' => '2026-06-30 12:00:00', + ]]))->toBe('economic_booked'); + + expect(invoicing_period_invoice_state_invoke('periodInvoiceCollectionState', [[ + 'booked_invoice_id' => null, + 'processor' => 1, + 'external_id' => 'draft-2', + 'error_message' => null, + 'closed_at' => '2026-06-30 12:00:00', + ]]))->toBe('economic_draft'); + + expect(invoicing_period_invoice_state_invoke('periodInvoiceCollectionState', [[ + 'processor' => 2, + 'external_id' => 'stripe-id', + 'closed_at' => '2026-06-30 12:00:00', + ]]))->toBe('closed'); + + expect(invoicing_period_invoice_state_invoke('periodInvoiceCollectionState', [[]]))->toBe('open'); +}); + +it('adds transaction states and collection summaries without losing logical orders', function (): void { + $customer = [ + 'customer_number' => 42424242, + 'transactions' => [ + ['id' => 11, 'amount' => 100.5, 'invoice_collection_id' => 501, 'booked' => false], + ['id' => 12, 'amount' => 50.0, 'invoice_collection_id' => 501, 'booked' => false], + ['id' => 13, 'amount' => 25.0, 'invoice_collection_id' => 502, 'booked' => false], + ['id' => 14, 'amount' => 10.0, 'invoice_collection_id' => null, 'booked' => false, 'completed_at' => '2026-06-30 10:00:00'], + ], + 'queue' => ['invoice_collection_ids' => []], + 'draft' => ['invoice_collection_ids' => [503]], + ]; + $metadata = [ + 501 => ['id' => 501, 'invoice_collection_id' => 501, 'customer_number' => 42424242, 'state' => 'economic_booked'], + 502 => ['id' => 502, 'invoice_collection_id' => 502, 'customer_number' => 42424242, 'state' => 'closed'], + 503 => ['id' => 503, 'invoice_collection_id' => 503, 'customer_number' => 42424242, 'state' => 'economic_draft'], + ]; + + $result = invoicing_period_invoice_state_invoke('applyPeriodInvoiceStateOverlayToCustomer', [$customer, $metadata]); + + expect(array_column($result['transactions'], 'invoice_state'))->toBe([ + 'economic_booked', + 'economic_booked', + 'closed', + 'closed', + ]); + expect(array_column($result['transactions'], 'booked'))->toBe([true, true, false, false]); + expect($result['invoice_collections'])->toHaveCount(3); + expect($result['invoice_collections'][0])->toMatchArray([ + 'id' => 501, + 'order_ids' => [11, 12], + 'order_count' => 2, + 'total_net_amount' => 150.5, + ]); + expect($result['invoice_collections'][2])->toMatchArray([ + 'id' => 503, + 'order_ids' => [], + 'order_count' => 0, + 'total_net_amount' => 0.0, + ]); +}); + +it('loads all referenced invoice collection metadata in one local query', function (): void { + $hadDb = array_key_exists('db', $GLOBALS); + $originalDb = $GLOBALS['db'] ?? null; + $fakeDb = new InvoicingPeriodInvoiceStateFakeDb([ + [ + 'id' => '501', + 'customer_number' => '42424242', + 'processor' => '1', + 'external_id' => 'draft-501', + 'booked_invoice_id' => null, + 'error_message' => null, + 'closed_at' => '2026-06-30 12:00:00', + ], + [ + 'id' => '502', + 'customer_number' => '42424242', + 'processor' => '1', + 'external_id' => 'draft-502', + 'booked_invoice_id' => '7002', + 'error_message' => null, + 'closed_at' => '2026-06-30 12:00:00', + ], + ]); + $GLOBALS['db'] = $fakeDb; + + try { + $metadata = invoicing_period_invoice_state_invoke('getPeriodInvoiceCollectionMetadata', [[ + 'all' => [[ + 'customer_number' => 42424242, + 'transactions' => [ + ['invoice_collection_id' => 501], + ['invoice_collection_id' => 501], + ], + 'queue' => ['invoice_collection_ids' => [502]], + 'draft' => ['invoice_collection_ids' => []], + ]], + 'fixed_pricing' => [[ + 'customer_number' => 42424242, + 'transactions' => [['invoice_collection_id' => 502]], + ]], + ]]); + } finally { + if ($hadDb) { + $GLOBALS['db'] = $originalDb; + } else { + unset($GLOBALS['db']); + } + } + + expect($fakeDb->queryCount)->toBe(1); + expect($fakeDb->sql)->toContain('WHERE id IN (501,502)'); + expect($metadata[501]['state'])->toBe('economic_draft'); + expect($metadata[502]['state'])->toBe('economic_booked'); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrderAttachmentContentRouteTest.php b/services/nginx/app/tests/Unit/Orders/OrderAttachmentContentRouteTest.php new file mode 100644 index 00000000..78bcd7de --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrderAttachmentContentRouteTest.php @@ -0,0 +1,79 @@ +not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain("\$this->get('/orders/attachments/content'"); + expect($content)->toContain('(int)$attachment->object_id->value() !== $orderId'); + expect($content)->toContain("\$attachmentType !== 'orders'"); + expect($content)->toContain('$formattedAttachment->content->document'); + expect($content)->toContain('$formattedAttachment->content->image'); + expect($content)->toContain('$formattedAttachment->isWashCertificate()'); + expect($content)->toContain('$isWashCertificate ? new pdf_store() : $attachmentStore'); + expect($content)->toContain("header('Content-Disposition: '"); + expect($content)->toContain("header('Cache-Control: private, no-store')"); + expect($content)->toContain("header('X-Content-Type-Options: nosniff')"); +}); + +it('keeps the legacy link HTTPS and safely encodes attachment object paths', function (): void { + $store = new attachment_store(); + + expect($store->generateDirectDownloadUrl('folder/test_file.pdf')) + ->toBe('https://api.truckwash.io/files/folder/test_file.pdf'); + expect(fn() => $store->generateDirectDownloadUrl('../secret.pdf')) + ->toThrow(InvalidArgumentException::class); +}); + +it('downloads uploaded attachments and generated wash certificates through isolated temporary files', function (): void { + $previousRunApiTests = getenv('RUN_API_TESTS'); + $previousMinio = $GLOBALS['MINIO'] ?? null; + putenv('RUN_API_TESTS=1'); + $GLOBALS['MINIO'] = ['endpoint' => '', 'access_key' => '', 'secret_key' => '']; + + $attachmentStore = new attachment_store(); + $pdfStore = new pdf_store(); + $attachmentKey = 'unit/' . uniqid('attachment_', true) . '.txt'; + $certificateKey = 'pdf_' . uniqid('wash_certificate_', true) . '.pdf'; + $attachmentTemporaryPath = null; + $certificateTemporaryPath = null; + + try { + expect($attachmentStore->createObject($attachmentKey, 'attachment-content'))->toBeTrue(); + expect($pdfStore->createObject($certificateKey, '%PDF-1.4 wash certificate'))->toBeTrue(); + expect($attachmentStore->doesObjectExist($certificateKey))->toBeFalse(); + expect($pdfStore->doesObjectExist($certificateKey))->toBeTrue(); + + $attachmentTemporaryPath = $attachmentStore->downloadToTemporaryFile($attachmentKey); + $certificateTemporaryPath = $pdfStore->downloadToTemporaryFile($certificateKey); + + expect($attachmentTemporaryPath)->toBeFile(); + expect(file_get_contents($attachmentTemporaryPath))->toBe('attachment-content'); + expect($certificateTemporaryPath)->toBeFile(); + expect(file_get_contents($certificateTemporaryPath))->toBe('%PDF-1.4 wash certificate'); + expect(basename($attachmentTemporaryPath))->toStartWith('stored_object_'); + expect(basename($certificateTemporaryPath))->toStartWith('stored_object_'); + } finally { + foreach ([$attachmentTemporaryPath, $certificateTemporaryPath] as $temporaryPath) { + if (is_string($temporaryPath) && file_exists($temporaryPath)) { + unlink($temporaryPath); + } + } + if ($previousRunApiTests === false) { + putenv('RUN_API_TESTS'); + } else { + putenv('RUN_API_TESTS=' . $previousRunApiTests); + } + if ($previousMinio === null) { + unset($GLOBALS['MINIO']); + } else { + $GLOBALS['MINIO'] = $previousMinio; + } + } +}); diff --git a/services/nginx/app/traits/minio_t.php b/services/nginx/app/traits/minio_t.php index 57f461d1..ad1a6374 100644 --- a/services/nginx/app/traits/minio_t.php +++ b/services/nginx/app/traits/minio_t.php @@ -213,6 +213,39 @@ trait minio_t return self::getS3Client()->doesObjectExist(self::getBucket(), $key); } + public function downloadToTemporaryFile(string $key): string + { + if (trim($key) === '') { + throw new \InvalidArgumentException('Object key cannot be empty'); + } + + $path = tempnam(sys_get_temp_dir(), 'stored_object_'); + if ($path === false) { + throw new \RuntimeException('Unable to create temporary object file'); + } + + try { + if ($this->shouldUseLocalTestStorage()) { + if (!copy($this->getLocalTestObjectPath($key), $path)) { + throw new \RuntimeException('Unable to copy object from local storage'); + } + return $path; + } + + self::getS3Client()->getObject([ + 'Bucket' => self::getBucket(), + 'Key' => $key, + 'SaveAs' => $path, + ]); + return $path; + } catch (\Throwable $e) { + if (file_exists($path)) { + unlink($path); + } + throw $e; + } + } + /** * Store temp file from base64 string * @param string $base64 The base64 encoded string