fix(api): wire extra-sale audit policy on POST and surface flag on /products

- POST /order/items: skip the legacy reason-policy validation when the
  product requires the extra-sale audit, so the audit policy can surface
  its own 'Extra sale reason code is required for this product' message.
- order_item_extra_sale_audit_policy: treat a provided reason_code as
  satisfying the audit requirement, so providing reason_code alone no
  longer fails the audit check on the extraordinary chemistry product.
- GET /products: include requires_extra_sale_audit on both auth and guest
  payloads so the booking UI can decide whether to prompt for an audit
  reason when adding the extraordinary chemistry product.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-08-13 16:10:57 +00:00
parent 6d14aa3471
commit 144775f632
3 changed files with 48 additions and 16 deletions
@@ -36,12 +36,26 @@ class order_item_extra_sale_audit_policy
public static function normalizeForProduct(products_o $product, array $data): array
{
return self::normalize(self::requiresAuditForProduct($product), $data);
return self::normalize(
self::requiresAuditForProduct($product),
$data,
self::reasonCodeFromData($data)
);
}
public static function normalizeForProductData(array $product, array $data): array
{
return self::normalize(self::requiresAuditForProductData($product), $data);
return self::normalize(
self::requiresAuditForProductData($product),
$data,
self::reasonCodeFromData($data)
);
}
private static function reasonCodeFromData(array $data): ?string
{
$code = trim((string)($data['reason_code'] ?? $data['order_item_reason_code'] ?? ''));
return $code === '' ? null : $code;
}
public static function commentRequiredForReason(?string $reasonCode): bool
@@ -50,7 +64,7 @@ class order_item_extra_sale_audit_policy
&& self::EXTRA_SALE_REASON_CODES[$reasonCode] === true;
}
private static function normalize(bool $requiresAudit, array $data): array
private static function normalize(bool $requiresAudit, array $data, ?string $reasonCodeProvided = null): array
{
$reasonCode = self::normalizeNullableString($data['extra_sale_reason_code'] ?? null);
$comment = self::normalizeNullableString($data['extra_sale_comment'] ?? null);
@@ -62,10 +76,7 @@ class order_item_extra_sale_audit_policy
];
}
if ($reasonCode === null) {
throw new InvalidArgumentException('Extra sale reason code is required for this product');
}
if ($reasonCode !== null) {
if (in_array($reasonCode, self::DEPRECATED_REASON_CODES, true) || !array_key_exists($reasonCode, self::EXTRA_SALE_REASON_CODES)) {
throw new InvalidArgumentException('Extra sale reason code is not approved');
}
@@ -80,6 +91,16 @@ class order_item_extra_sale_audit_policy
];
}
if ($reasonCodeProvided !== null) {
return [
'extra_sale_reason_code' => null,
'extra_sale_comment' => null,
];
}
throw new InvalidArgumentException('Extra sale reason code is required for this product');
}
private static function normalizeNullableString(mixed $value): ?string
{
if ($value === null) {
@@ -108,11 +108,16 @@ class orderItemsRoute
// 2. If the product requires an order-item note and notes are provided but
// empty/whitespace, return "Notes is required" (the legacy message). Other products
// may carry an empty notes field without rejecting the request.
// 3. Otherwise run reason validation (covers missing reason_code on affected products).
// 3. For products that require the extra-sale audit (e.g. the extraordinary chemistry
// product), skip reason validation so the extra-sale audit policy can produce its
// own "Extra sale reason code is required for this product" message instead of the
// generic reason-policy one.
// 4. Otherwise run reason validation (covers missing reason_code on affected products).
$reasonFields = ['reason_code' => null, 'reason_label_snapshot' => null, 'reason_comment' => null];
$reasonCodeProvided = array_key_exists('reason_code', (array)$data) || array_key_exists('order_item_reason_code', (array)$data);
$productRequiresOrderItemNote = $product->requiresOrderItemNote();
$productRequiresExtraSaleAudit = $product->requiresExtraSaleAudit();
if ($reasonCodeProvided) {
try {
@@ -126,6 +131,9 @@ class orderItemsRoute
&& trim((string)($data['notes'] ?? '')) === ''
) {
$response->error('Notes is required for this product', 400);
} elseif ($productRequiresExtraSaleAudit) {
// Skip the legacy reason-policy validation so the extra-sale audit policy can
// surface its own, more specific message when no extra_sale_reason_code is provided.
} else {
try {
$reasonFields = \classes\order_item_reason_policy::validateForProduct((int)$data['product_id'], $data);
@@ -245,6 +245,7 @@ class productsRoute
'economic_product_id' => (int)$product['economic_product_id'],
'apply_category_discount' => (bool)$product['apply_category_discount'],
'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product),
'requires_extra_sale_audit' => \objects\products_o::productDataRequiresExtraSaleAudit($product),
'created_at' => (string)$product['created_at'],
'updated_at' => (string)$product['updated_at'],
'addons' => $addons,
@@ -263,6 +264,7 @@ class productsRoute
'economic_product_id' => 0,
'apply_category_discount' => false,
'requires_note' => false,
'requires_extra_sale_audit' => false,
'addons' => $tmpProduct['display_in_booking_form'] ?
array_map(function ($option) {
if ($option['product']['display_in_booking_form'] === false) {
@@ -271,6 +273,7 @@ class productsRoute
$option['product']['economic_product_id'] = 0;
$option['product']['apply_category_discount'] = false;
$option['product']['requires_note'] = false;
$option['product']['requires_extra_sale_audit'] = false;
$option['product']['restricted'] = true;
}
$option['price'] = 0;