{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://remizo.dev/schemas/v1/offer.schema.json",
  "title": "Offer",
  "description": "Remizo offer definition: trigger -> condition tree (AND/OR/NOT groups of typed criteria) -> actions, plus stacking, limits and validity. This exact document is what the studio edits, what offer_versions.definition stores, and what the engine compiles in memory once published. Amounts are integer cents; percentages are basis points (bp, 10000 = 100%).",
  "type": "object",
  "additionalProperties": false,
  "required": ["id", "code", "name", "status", "version", "trigger", "criteria", "actions", "stacking", "validity"],
  "properties": {
    "schemaVersion": { "type": "string", "const": "1" },
    "id": { "type": "string", "format": "uuid", "description": "Stable offer id across versions." },
    "code": { "type": "string", "pattern": "^[A-Z0-9][A-Z0-9_-]{1,31}$", "description": "Human code, unique per workspace, immutable (used in POS messages and WsOne offerNumber)." },
    "name": { "type": "string", "minLength": 1, "maxLength": 120 },
    "description": { "type": "string", "maxLength": 2000 },
    "folderId": { "type": ["string", "null"], "format": "uuid" },
    "tags": { "type": "array", "uniqueItems": true, "items": { "type": "string", "maxLength": 40 } },
    "status": { "$ref": "#/$defs/OfferStatus" },
    "version": { "type": "integer", "minimum": 1, "description": "Version number of this definition (offer_versions.version_no)." },
    "publishedAt": { "type": ["string", "null"], "format": "date-time" },
    "trigger": { "$ref": "#/$defs/Trigger" },
    "criteria": {
      "$ref": "#/$defs/CriterionNode",
      "description": "Root of the condition tree. An empty AND group means \"always\"."
    },
    "actions": {
      "type": "array",
      "minItems": 1,
      "maxItems": 20,
      "items": { "$ref": "#/$defs/Action" },
      "description": "Executed in order when the criteria hold. Discount actions of the same offer are summed."
    },
    "stacking": { "$ref": "#/$defs/Stacking" },
    "limits": { "$ref": "#/$defs/Limits" },
    "validity": { "$ref": "#/$defs/Validity" },
    "coupon": { "$ref": "#/$defs/CouponRenderDefaults" },
    "ui": {
      "type": "object",
      "description": "Studio-only data (React Flow node positions, notes). Ignored by the engine.",
      "additionalProperties": true
    },
    "meta": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "createdAt": { "type": "string", "format": "date-time" },
        "createdBy": { "type": "string" },
        "updatedAt": { "type": "string", "format": "date-time" },
        "updatedBy": { "type": "string" },
        "templateId": { "type": ["string", "null"], "format": "uuid", "description": "Ready-made template this offer was created from." }
      }
    }
  },
  "$defs": {
    "Cents": { "type": "integer", "minimum": 0, "maximum": 9223372036854775807 },
    "Bp": { "type": "integer", "minimum": 0, "maximum": 1000000, "description": "Basis points: 10000 = 100%. Multipliers may exceed 10000." },
    "I18nText": {
      "type": "object",
      "description": "Map of BCP-47 language -> text. The workspace default locale key is required.",
      "minProperties": 1,
      "propertyNames": { "pattern": "^[a-z]{2}(-[A-Z]{2})?$" },
      "additionalProperties": { "type": "string", "maxLength": 500 }
    },
    "Weekday": { "type": "string", "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] },
    "Period": {
      "type": "string",
      "enum": ["ever", "day", "week", "month", "quarter", "year", "rolling_days", "validity"],
      "description": "Calendar periods are computed in the offer/workspace timezone. rolling_days requires periodDays. validity = the offer validity window."
    },
    "StringOp": { "type": "string", "enum": ["eq", "neq", "in", "not_in", "contains", "starts_with", "regex", "exists", "not_exists"] },
    "NumberOp": { "type": "string", "enum": ["eq", "neq", "gt", "gte", "lt", "lte", "between", "exists", "not_exists"] },
    "Rounding": { "type": "string", "enum": ["down", "half_up", "up"], "default": "half_up" },
    "Expiry": {
      "type": "object",
      "additionalProperties": false,
      "required": ["mode"],
      "description": "Expiry rule for credited counter units. none = counter type default.",
      "properties": {
        "mode": { "type": "string", "enum": ["counter_default", "never", "days", "end_of_month", "end_of_year", "fixed_date"] },
        "days": { "type": "integer", "minimum": 1 },
        "date": { "type": "string", "format": "date" }
      }
    },

    "OfferStatus": {
      "type": "string",
      "enum": ["draft", "pending_approval", "approved", "published", "suspended", "expired", "archived"],
      "description": "draft -> pending_approval -> approved -> published; published <-> suspended (emergency stop); published -> expired (validity.to passed); any -> archived. Only published versions are loaded by the engine."
    },

    "Trigger": {
      "type": "object",
      "additionalProperties": false,
      "required": ["type"],
      "properties": {
        "type": {
          "type": "string",
          "enum": ["transaction", "event", "coupon"],
          "description": "transaction: evaluated on every ticket. event: evaluated when a customer event occurs (no ticket). coupon: evaluated on tickets only when one of the trigger coupons is presented; the coupon is redeemed when the offer applies."
        },
        "event": {
          "type": "object",
          "additionalProperties": false,
          "required": ["types"],
          "properties": {
            "types": {
              "type": "array",
              "minItems": 1,
              "uniqueItems": true,
              "items": { "type": "string", "enum": ["signup", "card_created", "birthday", "anniversary_signup", "status_changed", "segment_entered", "segment_left", "counter_threshold", "custom"] }
            },
            "customTypes": { "type": "array", "items": { "type": "string", "maxLength": 64 } },
            "counterThreshold": {
              "type": "object",
              "additionalProperties": false,
              "required": ["counterTypeCode", "threshold"],
              "properties": {
                "counterTypeCode": { "type": "string" },
                "threshold": { "type": "integer" },
                "direction": { "type": "string", "enum": ["crosses_up", "crosses_down"], "default": "crosses_up" }
              }
            }
          }
        },
        "coupon": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "templateIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
            "campaignIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
            "codes": { "type": "array", "items": { "type": "string" }, "description": "Static promo codes (reusable, not stored as coupons)." },
            "redeem": { "type": "boolean", "default": true, "description": "Mark the coupon used at commit." }
          }
        }
      },
      "allOf": [
        { "if": { "properties": { "type": { "const": "event" } } }, "then": { "required": ["event"] } },
        { "if": { "properties": { "type": { "const": "coupon" } } }, "then": { "required": ["coupon"] } }
      ]
    },

    "CriterionNode": {
      "oneOf": [
        { "$ref": "#/$defs/Group" },
        { "$ref": "#/$defs/Criterion" }
      ]
    },
    "Group": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "op", "children"],
      "properties": {
        "kind": { "const": "group" },
        "id": { "$ref": "#/$defs/NodeId" },
        "op": { "type": "string", "enum": ["and", "or", "not"], "description": "not takes exactly one child." },
        "label": { "type": "string", "maxLength": 120 },
        "children": { "type": "array", "items": { "$ref": "#/$defs/CriterionNode" } }
      },
      "if": { "properties": { "op": { "const": "not" } } },
      "then": { "properties": { "children": { "minItems": 1, "maxItems": 1 } } }
    },
    "NodeId": { "type": "string", "pattern": "^[a-zA-Z0-9_-]{1,40}$", "description": "Unique within the offer. Needed when an action targets this criterion's matching lines." },
    "CriterionBase": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "type", "params"],
      "properties": {
        "kind": { "const": "criterion" },
        "id": { "$ref": "#/$defs/NodeId" },
        "type": { "$ref": "#/$defs/CriterionType" },
        "label": { "type": "string", "maxLength": 120 },
        "negate": { "type": "boolean", "default": false },
        "params": { "type": "object" }
      }
    },
    "CriterionType": {
      "type": "string",
      "enum": [
        "ticket_amount", "ticket_item_count", "date_range", "time_range", "weekdays", "store", "channel",
        "customer_identified", "customer_attribute", "loyalty_status", "segment", "customer_anniversary", "first_purchase",
        "coupon_presented", "payment_method", "counter_balance", "usage_count", "ab_variant", "event", "custom_cel",
        "article_ean", "article_ref", "article_list", "article_category", "article_rayon", "article_univers", "article_brand",
        "article_attribute", "article_price", "article_lot"
      ]
    },
    "Criterion": {
      "allOf": [
        { "$ref": "#/$defs/CriterionBase" },
        {
          "oneOf": [
            { "$ref": "#/$defs/criteria/TicketAmount" },
            { "$ref": "#/$defs/criteria/TicketItemCount" },
            { "$ref": "#/$defs/criteria/DateRange" },
            { "$ref": "#/$defs/criteria/TimeRange" },
            { "$ref": "#/$defs/criteria/Weekdays" },
            { "$ref": "#/$defs/criteria/Store" },
            { "$ref": "#/$defs/criteria/Channel" },
            { "$ref": "#/$defs/criteria/CustomerIdentified" },
            { "$ref": "#/$defs/criteria/CustomerAttribute" },
            { "$ref": "#/$defs/criteria/LoyaltyStatus" },
            { "$ref": "#/$defs/criteria/Segment" },
            { "$ref": "#/$defs/criteria/CustomerAnniversary" },
            { "$ref": "#/$defs/criteria/FirstPurchase" },
            { "$ref": "#/$defs/criteria/CouponPresented" },
            { "$ref": "#/$defs/criteria/PaymentMethod" },
            { "$ref": "#/$defs/criteria/CounterBalance" },
            { "$ref": "#/$defs/criteria/UsageCount" },
            { "$ref": "#/$defs/criteria/AbVariant" },
            { "$ref": "#/$defs/criteria/Event" },
            { "$ref": "#/$defs/criteria/CustomCel" },
            { "$ref": "#/$defs/criteria/ArticleEan" },
            { "$ref": "#/$defs/criteria/ArticleRef" },
            { "$ref": "#/$defs/criteria/ArticleList" },
            { "$ref": "#/$defs/criteria/ArticleCategory" },
            { "$ref": "#/$defs/criteria/ArticleRayon" },
            { "$ref": "#/$defs/criteria/ArticleUnivers" },
            { "$ref": "#/$defs/criteria/ArticleBrand" },
            { "$ref": "#/$defs/criteria/ArticleAttribute" },
            { "$ref": "#/$defs/criteria/ArticlePrice" },
            { "$ref": "#/$defs/criteria/ArticleLot" }
          ]
        }
      ]
    },

    "ArticleMatch": {
      "type": "object",
      "additionalProperties": false,
      "description": "Common thresholds for article criteria. The criterion holds when the matching lines (excluding flags no_promo for discounts) reach these thresholds. Matching lines become the criterion's eligible set, addressable by actions through target.criterionId.",
      "properties": {
        "minQty": { "type": "number", "minimum": 0, "default": 1, "description": "Minimum total quantity of matching lines." },
        "maxQty": { "type": "number", "minimum": 0 },
        "minAmountCents": { "$ref": "#/$defs/Cents", "description": "Minimum total of matching lineTotalCents." },
        "minLines": { "type": "integer", "minimum": 0 }
      }
    },

    "criteria": {
      "TicketAmount": {
        "properties": {
          "type": { "const": "ticket_amount" },
          "params": {
            "type": "object", "additionalProperties": false,
            "properties": {
              "base": { "type": "string", "enum": ["gross", "pre_tax", "eligible"], "default": "gross", "description": "eligible = sum of lines without flags no_promo/gift_card/deposit/fee." },
              "minCents": { "$ref": "#/$defs/Cents" },
              "maxCents": { "$ref": "#/$defs/Cents" }
            },
            "anyOf": [{ "required": ["minCents"] }, { "required": ["maxCents"] }]
          }
        }
      },
      "TicketItemCount": {
        "properties": {
          "type": { "const": "ticket_item_count" },
          "params": {
            "type": "object", "additionalProperties": false,
            "properties": {
              "countMode": { "type": "string", "enum": ["units", "lines"], "default": "units" },
              "min": { "type": "number", "minimum": 0 },
              "max": { "type": "number", "minimum": 0 }
            },
            "anyOf": [{ "required": ["min"] }, { "required": ["max"] }]
          }
        }
      },
      "DateRange": {
        "properties": {
          "type": { "const": "date_range" },
          "params": {
            "type": "object", "additionalProperties": false,
            "properties": {
              "from": { "type": "string", "format": "date" },
              "to": { "type": "string", "format": "date", "description": "Inclusive." },
              "recurringYearly": { "type": "boolean", "default": false, "description": "Ignore the year (e.g. every 24/12-26/12)." }
            },
            "anyOf": [{ "required": ["from"] }, { "required": ["to"] }]
          }
        }
      },
      "TimeRange": {
        "properties": {
          "type": { "const": "time_range" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["from", "to"],
            "properties": {
              "from": { "type": "string", "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$" },
              "to": { "type": "string", "pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$", "description": "Exclusive. If to < from the range crosses midnight." },
              "timezone": { "type": "string", "description": "IANA tz; default store timezone, then workspace timezone." }
            }
          }
        }
      },
      "Weekdays": {
        "properties": {
          "type": { "const": "weekdays" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["days"],
            "properties": { "days": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/Weekday" } } }
          }
        }
      },
      "Store": {
        "properties": {
          "type": { "const": "store" },
          "params": {
            "type": "object", "additionalProperties": false,
            "properties": {
              "mode": { "type": "string", "enum": ["in", "not_in"], "default": "in" },
              "storeCodes": { "type": "array", "items": { "type": "string" } },
              "storeGroups": { "type": "array", "items": { "type": "string" }, "description": "Store group codes (store_groups table)." }
            },
            "anyOf": [{ "required": ["storeCodes"] }, { "required": ["storeGroups"] }]
          }
        }
      },
      "Channel": {
        "properties": {
          "type": { "const": "channel" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["channels"],
            "properties": { "channels": { "type": "array", "minItems": 1, "items": { "type": "string", "enum": ["pos", "web", "app", "kiosk", "callcenter", "marketplace", "other"] } } }
          }
        }
      },
      "CustomerIdentified": {
        "properties": {
          "type": { "const": "customer_identified" },
          "params": {
            "type": "object", "additionalProperties": false,
            "properties": {
              "identifierTypes": { "type": "array", "items": { "type": "string", "enum": ["card", "web_account", "wallet", "prospect", "customer", "account", "email", "phone", "external"] }, "description": "Empty = any identifier." },
              "allowAutoCreated": { "type": "boolean", "default": true, "description": "false = the card must have existed before this ticket." },
              "customerStatuses": { "type": "array", "items": { "type": "string", "enum": ["active", "blocked", "prospect"] } }
            }
          }
        }
      },
      "CustomerAttribute": {
        "properties": {
          "type": { "const": "customer_attribute" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["attribute", "op"],
            "properties": {
              "attribute": { "type": "string", "description": "Built-in: gender, birthDate, signupAt, storeCode, optinMarketing, email, phone, city, postalCode, tags; or attributes.<key>." },
              "op": { "$ref": "#/$defs/StringOp" },
              "value": { "type": ["string", "number", "boolean", "null"] },
              "values": { "type": "array", "items": { "type": ["string", "number", "boolean"] } }
            }
          }
        }
      },
      "LoyaltyStatus": {
        "properties": {
          "type": { "const": "loyalty_status" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["statuses"],
            "properties": { "statuses": { "type": "array", "minItems": 1, "items": { "type": "string", "maxLength": 32 }, "description": "Loyalty status codes defined in workspace settings (e.g. bronze, silver, gold)." } }
          }
        }
      },
      "Segment": {
        "properties": {
          "type": { "const": "segment" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["segmentIds"],
            "properties": {
              "mode": { "type": "string", "enum": ["in_any", "in_all", "not_in"], "default": "in_any" },
              "segmentIds": { "type": "array", "minItems": 1, "items": { "type": "string", "format": "uuid" } }
            }
          }
        }
      },
      "CustomerAnniversary": {
        "properties": {
          "type": { "const": "customer_anniversary" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["event"],
            "properties": {
              "event": { "type": "string", "enum": ["birthday", "signup"] },
              "daysBefore": { "type": "integer", "minimum": 0, "default": 0 },
              "daysAfter": { "type": "integer", "minimum": 0, "default": 0 },
              "wholeMonth": { "type": "boolean", "default": false }
            }
          }
        }
      },
      "FirstPurchase": {
        "properties": {
          "type": { "const": "first_purchase" },
          "params": {
            "type": "object", "additionalProperties": false,
            "properties": { "scope": { "type": "string", "enum": ["ever", "store", "channel"], "default": "ever" } }
          }
        }
      },
      "CouponPresented": {
        "properties": {
          "type": { "const": "coupon_presented" },
          "params": {
            "type": "object", "additionalProperties": false,
            "description": "Holds when a valid (available, not expired) coupon of the given kind is presented. Does NOT redeem it (use trigger.type = coupon for that).",
            "properties": {
              "templateIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
              "campaignIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
              "codes": { "type": "array", "items": { "type": "string" }, "description": "Static promo codes." },
              "ownedByCustomer": { "type": "boolean", "default": false, "description": "The coupon must have been issued to the identified customer." }
            },
            "anyOf": [{ "required": ["templateIds"] }, { "required": ["campaignIds"] }, { "required": ["codes"] }]
          }
        }
      },
      "PaymentMethod": {
        "properties": {
          "type": { "const": "payment_method" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["methods"],
            "properties": {
              "methods": { "type": "array", "minItems": 1, "items": { "type": "string", "enum": ["cash", "card", "check", "voucher", "gift_card", "points", "mobile", "transfer", "credit", "other"] } },
              "brands": { "type": "array", "items": { "type": "string" } },
              "minAmountCents": { "$ref": "#/$defs/Cents" },
              "mode": { "type": "string", "enum": ["any", "all_payments", "majority"], "default": "any" }
            }
          }
        }
      },
      "CounterBalance": {
        "properties": {
          "type": { "const": "counter_balance" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["counterTypeCode"],
            "properties": {
              "counterTypeCode": { "type": "string" },
              "balance": { "type": "string", "enum": ["available", "total", "pending"], "default": "available" },
              "min": { "type": "integer" },
              "max": { "type": "integer" }
            },
            "anyOf": [{ "required": ["min"] }, { "required": ["max"] }]
          }
        }
      },
      "UsageCount": {
        "properties": {
          "type": { "const": "usage_count" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["scope", "max"],
            "description": "Number of previous applications of THIS offer (or another offer via offerId).",
            "properties": {
              "scope": { "type": "string", "enum": ["customer", "card", "store", "global"] },
              "offerId": { "type": "string", "format": "uuid" },
              "period": { "$ref": "#/$defs/Period", "default": "ever" },
              "periodDays": { "type": "integer", "minimum": 1 },
              "min": { "type": "integer", "minimum": 0 },
              "max": { "type": "integer", "minimum": 0 }
            }
          }
        }
      },
      "AbVariant": {
        "properties": {
          "type": { "const": "ab_variant" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["testId", "variants"],
            "properties": {
              "testId": { "type": "string", "format": "uuid" },
              "variants": { "type": "array", "minItems": 1, "items": { "type": "string", "maxLength": 20 } }
            }
          }
        }
      },
      "Event": {
        "properties": {
          "type": { "const": "event" },
          "params": {
            "type": "object", "additionalProperties": false,
            "description": "Only for trigger.type = event: filter on event payload.",
            "properties": {
              "path": { "type": "string", "description": "JSON path in event.payload (dot notation)." },
              "op": { "$ref": "#/$defs/StringOp" },
              "value": { "type": ["string", "number", "boolean", "null"] }
            },
            "required": ["path", "op"]
          }
        }
      },
      "CustomCel": {
        "properties": {
          "type": { "const": "custom_cel" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["expression"],
            "description": "CEL boolean expression over variables: ticket, customer, balances (map counterTypeCode -> {available,total}), store, now, ctx. Validated at save time by the hub (engine /mgmt/v1/cel/validate).",
            "properties": {
              "expression": { "type": "string", "maxLength": 4000 },
              "lineExpression": { "type": "string", "maxLength": 4000, "description": "Optional CEL over variable line; when set the criterion is article-level and its eligible set is the lines where lineExpression is true." },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            }
          }
        }
      },

      "ArticleEan": {
        "properties": {
          "type": { "const": "article_ean" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["eans"],
            "properties": {
              "eans": { "type": "array", "minItems": 1, "maxItems": 5000, "items": { "type": "string", "pattern": "^[0-9]{8,14}$" } },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            }
          }
        }
      },
      "ArticleRef": {
        "properties": {
          "type": { "const": "article_ref" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["refs"],
            "properties": {
              "refs": { "type": "array", "minItems": 1, "maxItems": 5000, "items": { "type": "string", "maxLength": 64 } },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            }
          }
        }
      },
      "ArticleList": {
        "properties": {
          "type": { "const": "article_list" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["listIds"],
            "description": "Product lists managed in the studio (product_lists table); matched on ean or ref.",
            "properties": {
              "listIds": { "type": "array", "minItems": 1, "items": { "type": "string", "format": "uuid" } },
              "mode": { "type": "string", "enum": ["in", "not_in"], "default": "in" },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            }
          }
        }
      },
      "ArticleCategory": {
        "properties": {
          "type": { "const": "article_category" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["codes"],
            "properties": {
              "codes": { "type": "array", "minItems": 1, "items": { "type": "string" } },
              "mode": { "type": "string", "enum": ["in", "not_in"], "default": "in" },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            }
          }
        }
      },
      "ArticleRayon": {
        "properties": {
          "type": { "const": "article_rayon" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["codes"],
            "properties": {
              "codes": { "type": "array", "minItems": 1, "items": { "type": "string" } },
              "mode": { "type": "string", "enum": ["in", "not_in"], "default": "in" },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            }
          }
        }
      },
      "ArticleUnivers": {
        "properties": {
          "type": { "const": "article_univers" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["codes"],
            "properties": {
              "codes": { "type": "array", "minItems": 1, "items": { "type": "string" } },
              "mode": { "type": "string", "enum": ["in", "not_in"], "default": "in" },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            }
          }
        }
      },
      "ArticleBrand": {
        "properties": {
          "type": { "const": "article_brand" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["brands"],
            "properties": {
              "brands": { "type": "array", "minItems": 1, "items": { "type": "string" } },
              "mode": { "type": "string", "enum": ["in", "not_in"], "default": "in" },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            }
          }
        }
      },
      "ArticleAttribute": {
        "properties": {
          "type": { "const": "article_attribute" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["attribute", "op"],
            "properties": {
              "attribute": { "type": "string", "description": "Line field (name, taxRateBp, qtyUnit) or attributes.<key>." },
              "op": { "$ref": "#/$defs/StringOp" },
              "value": { "type": ["string", "number", "boolean", "null"] },
              "values": { "type": "array", "items": { "type": ["string", "number", "boolean"] } },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            }
          }
        }
      },
      "ArticlePrice": {
        "properties": {
          "type": { "const": "article_price" },
          "params": {
            "type": "object", "additionalProperties": false,
            "properties": {
              "field": { "type": "string", "enum": ["unitPriceCents", "lineTotalCents"], "default": "unitPriceCents" },
              "minCents": { "$ref": "#/$defs/Cents" },
              "maxCents": { "$ref": "#/$defs/Cents" },
              "match": { "$ref": "#/$defs/ArticleMatch" }
            },
            "anyOf": [{ "required": ["minCents"] }, { "required": ["maxCents"] }]
          }
        }
      },
      "ArticleLot": {
        "properties": {
          "type": { "const": "article_lot" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["components"],
            "description": "A lot (bundle) is complete when every component is satisfied by distinct units. The criterion holds when at least minLots complete lots exist; its eligible set is the units consumed by the lots (cheapest-first allocation).",
            "properties": {
              "components": {
                "type": "array", "minItems": 1, "maxItems": 10,
                "items": {
                  "type": "object", "additionalProperties": false, "required": ["qty"],
                  "properties": {
                    "criterionId": { "$ref": "#/$defs/NodeId", "description": "Article criterion elsewhere in the tree whose eligible set feeds this component." },
                    "selector": { "$ref": "#/$defs/Criterion", "description": "Inline article criterion (alternative to criterionId)." },
                    "qty": { "type": "number", "exclusiveMinimum": 0 }
                  },
                  "oneOf": [{ "required": ["criterionId"] }, { "required": ["selector"] }]
                }
              },
              "minLots": { "type": "integer", "minimum": 1, "default": 1 },
              "maxLots": { "type": "integer", "minimum": 1, "description": "Cap on lots per ticket (default unlimited)." },
              "sameArticle": { "type": "boolean", "default": false, "description": "All units of a lot must share the same ean/ref." }
            }
          }
        }
      }
    },

    "Target": {
      "type": "object",
      "additionalProperties": false,
      "description": "Which ticket lines an article-level action applies to. Exactly one of all / criterionId / selector.",
      "properties": {
        "all": { "const": true, "description": "All lines without flag no_promo." },
        "criterionId": { "$ref": "#/$defs/NodeId", "description": "Eligible set of an article criterion of this offer." },
        "selector": { "$ref": "#/$defs/Criterion", "description": "Inline article criterion." },
        "sort": { "type": "string", "enum": ["cheapest_first", "most_expensive_first", "ticket_order"], "default": "cheapest_first" },
        "maxQty": { "type": "number", "exclusiveMinimum": 0, "description": "Limit the number of units affected." }
      },
      "oneOf": [{ "required": ["all"] }, { "required": ["criterionId"] }, { "required": ["selector"] }]
    },

    "ActionType": {
      "type": "string",
      "enum": [
        "discount_amount", "discount_percent", "discount_fixed_price", "cheapest_free", "free_product", "lot_price",
        "points_fixed", "points_per_amount", "points_multiplier", "counter_update",
        "coupon_issue", "message", "customer_tag", "customer_status", "custom_event", "stamp_card"
      ]
    },
    "ActionBase": {
      "type": "object",
      "additionalProperties": false,
      "required": ["type", "params"],
      "properties": {
        "id": { "$ref": "#/$defs/NodeId" },
        "type": { "$ref": "#/$defs/ActionType" },
        "label": { "type": "string", "maxLength": 120 },
        "params": { "type": "object" }
      }
    },
    "Action": {
      "allOf": [
        { "$ref": "#/$defs/ActionBase" },
        {
          "oneOf": [
            { "$ref": "#/$defs/actions/DiscountAmount" },
            { "$ref": "#/$defs/actions/DiscountPercent" },
            { "$ref": "#/$defs/actions/DiscountFixedPrice" },
            { "$ref": "#/$defs/actions/CheapestFree" },
            { "$ref": "#/$defs/actions/FreeProduct" },
            { "$ref": "#/$defs/actions/LotPrice" },
            { "$ref": "#/$defs/actions/PointsFixed" },
            { "$ref": "#/$defs/actions/PointsPerAmount" },
            { "$ref": "#/$defs/actions/PointsMultiplier" },
            { "$ref": "#/$defs/actions/CounterUpdate" },
            { "$ref": "#/$defs/actions/CouponIssue" },
            { "$ref": "#/$defs/actions/Message" },
            { "$ref": "#/$defs/actions/CustomerTag" },
            { "$ref": "#/$defs/actions/CustomerStatus" },
            { "$ref": "#/$defs/actions/CustomEvent" },
            { "$ref": "#/$defs/actions/StampCard" }
          ]
        }
      ]
    },
    "actions": {
      "DiscountAmount": {
        "properties": {
          "type": { "const": "discount_amount" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["amountCents"],
            "description": "Fixed discount. scope ticket: one amount on the ticket (capped at the eligible total). per_line / per_unit: on each targeted line / unit. eligible_total: one amount spread pro rata on the targeted lines.",
            "properties": {
              "amountCents": { "$ref": "#/$defs/Cents" },
              "scope": { "type": "string", "enum": ["ticket", "eligible_total", "per_line", "per_unit"], "default": "ticket" },
              "target": { "$ref": "#/$defs/Target", "description": "Required when scope != ticket." },
              "maxTotalCents": { "$ref": "#/$defs/Cents" }
            }
          }
        }
      },
      "DiscountPercent": {
        "properties": {
          "type": { "const": "discount_percent" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["percentBp"],
            "description": "Percentage discount. scope ticket: on the eligible ticket amount (spread pro rata over eligible lines in the breakdown). lines: on the targeted lines.",
            "properties": {
              "percentBp": { "type": "integer", "minimum": 1, "maximum": 10000 },
              "scope": { "type": "string", "enum": ["ticket", "lines"], "default": "ticket" },
              "target": { "$ref": "#/$defs/Target" },
              "maxTotalCents": { "$ref": "#/$defs/Cents" },
              "rounding": { "$ref": "#/$defs/Rounding" }
            }
          }
        }
      },
      "DiscountFixedPrice": {
        "properties": {
          "type": { "const": "discount_fixed_price" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["priceCents", "target"],
            "description": "Bring the targeted units to a fixed price (discount = unitPrice - price when positive).",
            "properties": {
              "priceCents": { "$ref": "#/$defs/Cents" },
              "target": { "$ref": "#/$defs/Target" }
            }
          }
        }
      },
      "CheapestFree": {
        "properties": {
          "type": { "const": "cheapest_free" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["target", "groupSize", "freeCount"],
            "description": "\"N bought, M cheapest free\" (or discounted by percentBp). Units of the target are sorted by unit price, grouped by groupSize; in each group the freeCount cheapest units get the discount.",
            "properties": {
              "target": { "$ref": "#/$defs/Target" },
              "groupSize": { "type": "integer", "minimum": 2 },
              "freeCount": { "type": "integer", "minimum": 1 },
              "percentBp": { "type": "integer", "minimum": 1, "maximum": 10000, "default": 10000 },
              "maxGroups": { "type": "integer", "minimum": 1 }
            }
          }
        }
      },
      "FreeProduct": {
        "properties": {
          "type": { "const": "free_product" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["product", "qty", "mode"],
            "description": "Offer a product. add_line: the result asks the POS to add the line at price 0 (flag remizo_free_product). discount_if_present: 100% discount on the product if already in the ticket, otherwise the action is skipped (reason free_product_absent).",
            "properties": {
              "product": {
                "type": "object", "additionalProperties": false,
                "properties": {
                  "ean": { "type": "string", "pattern": "^[0-9]{8,14}$" },
                  "ref": { "type": "string", "maxLength": 64 },
                  "name": { "type": "string", "maxLength": 200 }
                },
                "anyOf": [{ "required": ["ean"] }, { "required": ["ref"] }]
              },
              "qty": { "type": "integer", "minimum": 1, "default": 1 },
              "mode": { "type": "string", "enum": ["add_line", "discount_if_present"] },
              "perLotOf": { "$ref": "#/$defs/NodeId", "description": "Repeat per complete lot of this article_lot criterion." }
            }
          }
        }
      },
      "LotPrice": {
        "properties": {
          "type": { "const": "lot_price" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["lotCriterionId", "lotPriceCents"],
            "description": "Set the price of each complete lot (discount = lot units total - lotPriceCents, spread pro rata).",
            "properties": {
              "lotCriterionId": { "$ref": "#/$defs/NodeId" },
              "lotPriceCents": { "$ref": "#/$defs/Cents" },
              "maxLots": { "type": "integer", "minimum": 1 }
            }
          }
        }
      },
      "PointsFixed": {
        "properties": {
          "type": { "const": "points_fixed" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["counterTypeCode", "amount"],
            "description": "Credit a fixed number of units on a counter (points, cashback cents, visits...).",
            "properties": {
              "counterTypeCode": { "type": "string" },
              "amount": { "type": "integer", "minimum": 1 },
              "activationDelayDays": { "type": "integer", "minimum": 0, "default": 0 },
              "expiry": { "$ref": "#/$defs/Expiry" }
            }
          }
        }
      },
      "PointsPerAmount": {
        "properties": {
          "type": { "const": "points_per_amount" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["counterTypeCode", "points", "perCents"],
            "description": "Credit `points` units for every `perCents` of the base amount (e.g. 1 point per 100 cents).",
            "properties": {
              "counterTypeCode": { "type": "string" },
              "points": { "type": "integer", "minimum": 1 },
              "perCents": { "type": "integer", "minimum": 1 },
              "base": { "type": "string", "enum": ["gross", "pre_tax", "eligible", "after_discounts", "target_lines"], "default": "after_discounts", "description": "after_discounts = eligible amount minus Remizo discounts of lower or equal levels." },
              "target": { "$ref": "#/$defs/Target", "description": "Required when base = target_lines." },
              "rounding": { "$ref": "#/$defs/Rounding" },
              "minBaseCents": { "$ref": "#/$defs/Cents" },
              "maxPoints": { "type": "integer", "minimum": 1 },
              "activationDelayDays": { "type": "integer", "minimum": 0, "default": 0 },
              "expiry": { "$ref": "#/$defs/Expiry" }
            }
          }
        }
      },
      "PointsMultiplier": {
        "properties": {
          "type": { "const": "points_multiplier" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["counterTypeCode", "multiplierBp"],
            "description": "Multiply the units credited on this ticket by other offers (lower levels only). 20000 = x2.",
            "properties": {
              "counterTypeCode": { "type": "string" },
              "multiplierBp": { "type": "integer", "minimum": 1, "maximum": 1000000 },
              "appliesTo": { "type": "string", "enum": ["all_earned", "offer_ids"], "default": "all_earned" },
              "offerIds": { "type": "array", "items": { "type": "string", "format": "uuid" } },
              "rounding": { "$ref": "#/$defs/Rounding" }
            }
          }
        }
      },
      "CounterUpdate": {
        "properties": {
          "type": { "const": "counter_update" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["counterTypeCode", "op"],
            "description": "Technical counter operation (visits, stamps...). decrement fails the action (not the offer) when insufficient unless allowNegative on the counter type.",
            "properties": {
              "counterTypeCode": { "type": "string" },
              "op": { "type": "string", "enum": ["increment", "decrement", "set", "reset"] },
              "amount": { "type": "integer", "minimum": 0 },
              "expiry": { "$ref": "#/$defs/Expiry" }
            }
          }
        }
      },
      "CouponIssue": {
        "properties": {
          "type": { "const": "coupon_issue" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["templateId"],
            "description": "Issue a coupon to the customer (or anonymous ticket coupon when no customer). Rendering (png/pdf) happens asynchronously on the hub.",
            "properties": {
              "templateId": { "type": "string", "format": "uuid" },
              "quantity": { "type": "integer", "minimum": 1, "maximum": 10, "default": 1 },
              "validity": {
                "type": "object", "additionalProperties": false, "required": ["mode"],
                "properties": {
                  "mode": { "type": "string", "enum": ["template_default", "fixed", "rolling"] },
                  "from": { "type": "string", "format": "date-time" },
                  "to": { "type": "string", "format": "date-time" },
                  "startDelayDays": { "type": "integer", "minimum": 0, "default": 0 },
                  "days": { "type": "integer", "minimum": 1 }
                }
              },
              "render": { "type": "string", "enum": ["inherit", "none", "png", "pdf"], "default": "inherit", "description": "inherit = offer.coupon.render." },
              "faceValue": {
                "type": "object", "additionalProperties": false, "required": ["kind", "value"],
                "description": "Value printed on the coupon (informational; the discount itself is carried by the offer triggered by this coupon).",
                "properties": {
                  "kind": { "type": "string", "enum": ["amount_cents", "percent_bp", "points", "text"] },
                  "value": { "type": ["integer", "string"] }
                }
              },
              "linkedOfferId": { "type": "string", "format": "uuid", "description": "Offer (trigger.type = coupon) that this coupon unlocks; printed on the coupon." }
            }
          }
        }
      },
      "Message": {
        "properties": {
          "type": { "const": "message" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["text"],
            "description": "Text returned to the POS. Placeholders: {{customer.firstName}}, {{balance.<counterTypeCode>.available}}, {{offer.name}}, {{discount.total}}, {{points.earned}}.",
            "properties": {
              "placement": { "type": "string", "enum": ["ticket", "screen", "both"], "default": "ticket" },
              "text": { "$ref": "#/$defs/I18nText" },
              "onlyWhenApplied": { "type": "boolean", "default": true }
            }
          }
        }
      },
      "CustomerTag": {
        "properties": {
          "type": { "const": "customer_tag" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["tag", "op"],
            "properties": {
              "tag": { "type": "string", "maxLength": 40 },
              "op": { "type": "string", "enum": ["add", "remove"] }
            }
          }
        }
      },
      "CustomerStatus": {
        "properties": {
          "type": { "const": "customer_status" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["status"],
            "description": "Set the loyalty status (tier). Applied at commit.",
            "properties": {
              "status": { "type": "string", "maxLength": 32 },
              "onlyUpgrade": { "type": "boolean", "default": true }
            }
          }
        }
      },
      "CustomEvent": {
        "properties": {
          "type": { "const": "custom_event" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["eventType"],
            "description": "Emit a customer event (customer_events table + RabbitMQ custom.event) usable by event-triggered offers and webhooks.",
            "properties": {
              "eventType": { "type": "string", "maxLength": 64 },
              "payload": { "type": "object", "additionalProperties": true }
            }
          }
        }
      },
      "StampCard": {
        "properties": {
          "type": { "const": "stamp_card" },
          "params": {
            "type": "object", "additionalProperties": false, "required": ["counterTypeCode", "stampsRequired", "reward"],
            "description": "Stamp card: adds stampsPerVisit stamps (once per ticket) to the counter; each time the card is full (stampsRequired), the reward is granted and the card restarts with the extra stamps.",
            "properties": {
              "counterTypeCode": { "type": "string", "description": "Counter holding the stamps of the current card (kind visits)." },
              "stampsRequired": { "type": "integer", "minimum": 2, "maximum": 50 },
              "stampsPerVisit": { "type": "integer", "minimum": 1, "maximum": 10, "default": 1 },
              "reward": {
                "type": "object", "additionalProperties": false, "required": ["type"],
                "properties": {
                  "type": { "type": "string", "enum": ["coupon", "points"] },
                  "templateId": { "type": "string", "format": "uuid", "description": "Coupon template (type coupon)." },
                  "quantity": { "type": "integer", "minimum": 1, "maximum": 10, "default": 1 },
                  "counterTypeCode": { "type": "string", "description": "Points counter credited (type points)." },
                  "amount": { "type": "integer", "minimum": 1, "description": "Points credited per full card (type points)." },
                  "label": { "type": "string", "maxLength": 120, "description": "Reward shown on the card (e.g. a free coffee)." }
                }
              },
              "design": {
                "type": "object", "additionalProperties": false,
                "description": "Card look (studio, POS, customer apps).",
                "properties": {
                  "title": { "type": "string", "maxLength": 60 },
                  "color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
                  "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
                  "icon": { "type": "string", "maxLength": 32 }
                }
              }
            }
          }
        }
      }
    },

    "Stacking": {
      "type": "object",
      "additionalProperties": false,
      "required": ["level", "mode"],
      "description": "Levels run in ascending order; level N sees the ticket after discounts of levels < N. Within one level: offers in mode cumulative all apply; among offers in mode best_offer only the one with the highest total value (discount cents, then points) applies, the others are rejected with reason lost_best_offer; offers in mode best_line compete per line: on each line only the best discount wins (lost_best_line). Priority breaks ties (higher first) and orders cumulative execution.",
      "properties": {
        "level": { "type": "integer", "minimum": 0, "maximum": 99 },
        "mode": { "type": "string", "enum": ["cumulative", "best_offer", "best_line"] },
        "priority": { "type": "integer", "minimum": 0, "maximum": 1000, "default": 100 },
        "exclusions": {
          "type": "array", "uniqueItems": true,
          "items": { "type": "string", "format": "uuid" },
          "description": "Offer ids that cannot apply on the same ticket as this one. Symmetric: the engine treats A excludes B as B excludes A; the lower-priority (or later-level) one is rejected with reason excluded_by."
        },
        "exclusiveTags": {
          "type": "array", "uniqueItems": true, "items": { "type": "string", "maxLength": 40 },
          "description": "Offers sharing one of these tags are mutually exclusive on a ticket."
        },
        "exclusive": { "type": "boolean", "default": false, "description": "When applied, no other offer of a higher level applies on this ticket." }
      }
    },

    "Limits": {
      "type": "object",
      "additionalProperties": false,
      "description": "Budgets. Counters are checked at evaluation and consumed at commit (see architecture.md, limits).",
      "properties": {
        "global": {
          "type": "object", "additionalProperties": false,
          "properties": {
            "maxUses": { "type": "integer", "minimum": 1, "description": "Total number of tickets the offer may apply to." },
            "maxAmountCents": { "$ref": "#/$defs/Cents", "description": "Total discount budget." },
            "maxPoints": { "type": "integer", "minimum": 1 },
            "maxCoupons": { "type": "integer", "minimum": 1 },
            "onExhausted": { "type": "string", "enum": ["reject", "suspend"], "default": "reject", "description": "suspend = also flip the offer status to suspended (event offer.budget_exhausted)." }
          }
        },
        "perCustomer": {
          "type": "object", "additionalProperties": false,
          "properties": {
            "maxUses": { "type": "integer", "minimum": 1 },
            "maxAmountCents": { "$ref": "#/$defs/Cents" },
            "period": { "$ref": "#/$defs/Period", "default": "ever" },
            "periodDays": { "type": "integer", "minimum": 1 },
            "identifyBy": { "type": "string", "enum": ["customer", "card"], "default": "customer" },
            "whenAnonymous": { "type": "string", "enum": ["allow", "reject"], "default": "allow", "description": "Behaviour when the ticket has no identified customer." }
          }
        },
        "perTicket": {
          "type": "object", "additionalProperties": false,
          "properties": {
            "maxApplications": { "type": "integer", "minimum": 1, "default": 1, "description": "How many times the offer may repeat in one ticket (e.g. once per lot)." },
            "maxDiscountCents": { "$ref": "#/$defs/Cents" }
          }
        },
        "perStore": {
          "type": "object", "additionalProperties": false,
          "properties": {
            "maxUses": { "type": "integer", "minimum": 1 },
            "period": { "$ref": "#/$defs/Period", "default": "ever" },
            "periodDays": { "type": "integer", "minimum": 1 }
          }
        }
      }
    },

    "Validity": {
      "type": "object",
      "additionalProperties": false,
      "required": ["from"],
      "description": "Hard activation window; outside it the engine does not even load the offer (reason not_in_validity when evaluated through the simulator). Finer schedules use date_range / time_range / weekdays criteria.",
      "properties": {
        "from": { "type": "string", "format": "date-time" },
        "to": { "type": ["string", "null"], "format": "date-time", "description": "null = open-ended." },
        "timezone": { "type": "string", "description": "IANA timezone for calendar computations; default workspace timezone." }
      }
    },

    "CouponRenderDefaults": {
      "type": "object",
      "additionalProperties": false,
      "required": ["render"],
      "description": "Default rendering for coupons issued by this offer. none = code only (default).",
      "properties": {
        "render": { "type": "string", "enum": ["none", "png", "pdf"], "default": "none" },
        "templateId": { "type": ["string", "null"], "format": "uuid", "description": "Coupon template whose design is rendered. Required when render != none." }
      },
      "if": { "properties": { "render": { "enum": ["png", "pdf"] } } },
      "then": { "required": ["templateId"] }
    }
  }
}
