Improve invoice period data and POS add-on validation

This commit is contained in:
Jeppe B
2026-07-15 17:04:52 +02:00
parent 0feb705059
commit 879dfcf79a
18 changed files with 1499 additions and 136 deletions
+218 -5
View File
@@ -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:
@@ -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;
}
}
@@ -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);
}
}
@@ -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',
+1 -1
View File
@@ -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);
}
/**
@@ -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);
+3
View File
@@ -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,
+218 -5
View File
@@ -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:
@@ -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<int,array<string,mixed>> 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'] ?? [];
@@ -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),
];
+190 -61
View File
@@ -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
@@ -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');
@@ -0,0 +1,26 @@
<?php
it('resolves persisted booked invoices before probing for a draft', function (): void {
$content = file_get_contents(app_path('routes/orderInvoicesRoute.php'));
expect($content)->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");
});
@@ -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;
@@ -0,0 +1,24 @@
<?php
it('documents period invoice states and authenticated attachment content', function (): void {
$openApiFiles = [
app_path('openapi.yaml'),
dirname(app_path(), 3) . '/openapi.yaml',
];
foreach ($openApiFiles as $openApiFile) {
expect($openApiFile)->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]');
}
});
@@ -0,0 +1,166 @@
<?php
app_require('routes/InvoicingPeriodRoute.php');
use routes\InvoicingPeriodRoute;
function invoicing_period_invoice_state_invoke(string $method, array $args = []): mixed
{
$reflection = new ReflectionClass(InvoicingPeriodRoute::class);
$target = $reflection->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');
});
@@ -0,0 +1,79 @@
<?php
app_require('classes/attachment_store.php');
use classes\attachment_store;
use classes\pdf_store;
it('registers authenticated attachment streaming with ownership and safe headers', function (): void {
$content = file_get_contents(app_path('routes/ordersRoute.php'));
expect($content)->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;
}
}
});
+33
View File
@@ -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