{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://remizo.dev/schemas/v1/ticket.schema.json",
  "title": "Ticket",
  "description": "Canonical Remizo ticket. Every input adapter (native API, WsOne, future POS vendors) maps its payload to this document before evaluation. All amounts are integer cents (int64) in the ticket currency; the POS computes totals, the engine never recomputes them (coherence check = warning only).",
  "type": "object",
  "additionalProperties": false,
  "required": ["header", "lines"],
  "properties": {
    "schemaVersion": {
      "type": "string",
      "const": "1",
      "description": "Ticket document version. Defaults to \"1\" when absent."
    },
    "header": { "$ref": "#/$defs/Header" },
    "customer": { "$ref": "#/$defs/Customer" },
    "lines": {
      "type": "array",
      "minItems": 0,
      "maxItems": 2000,
      "items": { "$ref": "#/$defs/Line" }
    },
    "payments": {
      "type": "array",
      "items": { "$ref": "#/$defs/Payment" },
      "description": "Payments announced by the POS. May be empty at evaluation time (before the customer pays) and completed at commit."
    },
    "coupons": {
      "type": "array",
      "maxItems": 50,
      "items": { "$ref": "#/$defs/PresentedCoupon" },
      "description": "Coupon / promo codes presented at checkout."
    },
    "options": { "$ref": "#/$defs/Options" },
    "ext": {
      "type": "object",
      "description": "Free-form extension bag preserved verbatim; reachable from CEL custom criteria as ticket.ext.*. Adapters put vendor-specific fields here.",
      "additionalProperties": true
    }
  },
  "$defs": {
    "Cents": {
      "type": "integer",
      "minimum": -9223372036854775808,
      "maximum": 9223372036854775807,
      "description": "Integer amount in minor currency units (cents)."
    },
    "NonNegativeCents": {
      "type": "integer",
      "minimum": 0,
      "maximum": 9223372036854775807
    },
    "Header": {
      "type": "object",
      "additionalProperties": false,
      "required": ["ticketNumber", "storeCode", "posId", "businessDate", "timestamp", "currency", "totals"],
      "properties": {
        "ticketNumber": {
          "type": "string",
          "minLength": 1,
          "maxLength": 64,
          "description": "Receipt number as printed by the POS. Unique per (storeCode, posId, businessDate)."
        },
        "storeCode": { "type": "string", "minLength": 1, "maxLength": 32, "description": "Store identifier known to Remizo (stores table)." },
        "posId": { "type": "string", "minLength": 1, "maxLength": 32, "description": "Cash register identifier (number or IP)." },
        "cashierId": { "type": "string", "maxLength": 64 },
        "businessDate": {
          "type": "string",
          "format": "date",
          "description": "Business day of the ticket (YYYY-MM-DD) in the store timezone."
        },
        "timestamp": {
          "type": "string",
          "format": "date-time",
          "description": "Ticket date/time, RFC 3339 with offset."
        },
        "channel": {
          "$ref": "#/$defs/Channel"
        },
        "currency": {
          "type": "string",
          "pattern": "^[A-Z]{3}$",
          "description": "ISO 4217 code. Must equal the workspace currency."
        },
        "locale": { "type": "string", "pattern": "^[a-z]{2}(-[A-Z]{2})?$", "description": "Used to pick message translations (default: workspace locale)." },
        "totals": { "$ref": "#/$defs/Totals" },
        "externalRef": { "type": "string", "maxLength": 128, "description": "Opaque reference from the calling system (order id, basket id)." },
        "originalTicketKey": {
          "type": "string",
          "description": "For refund tickets: the ticketKey of the original transaction (see architecture.md, ticket key)."
        },
        "kind": {
          "type": "string",
          "enum": ["sale", "refund"],
          "default": "sale"
        }
      }
    },
    "Totals": {
      "type": "object",
      "additionalProperties": false,
      "required": ["grossCents"],
      "description": "Totals as computed by the POS BEFORE Remizo offers. gross = sum of line totals after POS-side discounts.",
      "properties": {
        "grossCents": { "$ref": "#/$defs/NonNegativeCents", "description": "Amount all taxes included, before Remizo offers." },
        "preTaxCents": { "$ref": "#/$defs/NonNegativeCents" },
        "taxCents": { "$ref": "#/$defs/NonNegativeCents" },
        "posDiscountCents": { "$ref": "#/$defs/NonNegativeCents", "description": "Discounts already granted by the POS itself (not Remizo)." },
        "itemCount": { "type": "integer", "minimum": 0, "description": "Number of units announced by the POS (used by the coherence check)." }
      }
    },
    "Channel": {
      "type": "string",
      "enum": ["pos", "web", "app", "kiosk", "callcenter", "marketplace", "other"],
      "default": "pos"
    },
    "Customer": {
      "type": "object",
      "additionalProperties": false,
      "required": ["identifiers"],
      "properties": {
        "identifiers": {
          "type": "array",
          "minItems": 1,
          "maxItems": 10,
          "items": { "$ref": "#/$defs/Identifier" },
          "description": "Resolved in order; the first identifier that matches a known customer wins."
        },
        "autoCreateCard": {
          "type": "boolean",
          "default": true,
          "description": "When an identifier of type card is unknown, create the card and a minimal customer (workspace setting customers.autoCreateCard can forbid it)."
        },
        "prospect": {
          "type": "object",
          "description": "Minimal data supplied at checkout when a new customer/prospect is created.",
          "additionalProperties": false,
          "properties": {
            "email": { "type": "string", "format": "email" },
            "phone": { "type": "string", "maxLength": 32 },
            "firstName": { "type": "string", "maxLength": 100 },
            "lastName": { "type": "string", "maxLength": 100 },
            "optinMarketing": { "type": "boolean" }
          }
        }
      }
    },
    "Identifier": {
      "type": "object",
      "additionalProperties": false,
      "required": ["type", "value"],
      "properties": {
        "type": { "$ref": "#/$defs/IdentifierType" },
        "value": { "type": "string", "minLength": 1, "maxLength": 128 }
      }
    },
    "IdentifierType": {
      "type": "string",
      "description": "card = loyalty card number (WsOne type 1); web_account = e-commerce account (WsOne 10); wallet = mobile wallet id (WsOne 30); prospect = prospect id (WsOne 99); customer = Remizo customer UUID; account = Remizo account id; external = CRM external reference.",
      "enum": ["card", "web_account", "wallet", "prospect", "customer", "account", "email", "phone", "external"]
    },
    "Line": {
      "type": "object",
      "additionalProperties": false,
      "required": ["lineNo", "qty", "unitPriceCents", "lineTotalCents"],
      "properties": {
        "lineNo": { "type": "integer", "minimum": 1, "description": "Position in the ticket, unique within the ticket. Used to address discounts in the result." },
        "ean": { "type": "string", "pattern": "^[0-9]{8,14}$", "description": "EAN-8/13/14 or GTIN." },
        "ref": { "type": "string", "maxLength": 64, "description": "Retailer main reference (SKU)." },
        "name": { "type": "string", "maxLength": 200 },
        "category": { "type": "string", "maxLength": 64, "description": "Finest classification level (famille / sous-famille)." },
        "rayon": { "type": "string", "maxLength": 64, "description": "Department (rayon)." },
        "univers": { "type": "string", "maxLength": 64, "description": "Top classification level (univers)." },
        "brand": { "type": "string", "maxLength": 100 },
        "qty": {
          "type": "number",
          "exclusiveMinimum": 0,
          "multipleOf": 0.001,
          "description": "Quantity; integer for unit items, up to 3 decimals for weight/volume items. Refund tickets use positive quantities with header.kind = refund."
        },
        "qtyUnit": { "type": "string", "enum": ["unit", "kg", "g", "l", "ml", "m"], "default": "unit" },
        "unitPriceCents": { "$ref": "#/$defs/NonNegativeCents", "description": "Unit price all taxes included, before POS discounts." },
        "unitPricePreTaxCents": { "$ref": "#/$defs/NonNegativeCents" },
        "posDiscountCents": { "$ref": "#/$defs/NonNegativeCents", "default": 0, "description": "Discount already applied by the POS on this line (total for the line)." },
        "lineTotalCents": { "$ref": "#/$defs/NonNegativeCents", "description": "qty x unitPriceCents - posDiscountCents, as computed by the POS. Remizo offers apply on this amount." },
        "taxRateBp": { "type": "integer", "minimum": 0, "maximum": 10000, "description": "VAT rate in basis points (2000 = 20%)." },
        "flags": {
          "type": "array",
          "uniqueItems": true,
          "items": {
            "type": "string",
            "enum": ["no_promo", "no_points", "gift_card", "service", "deposit", "fee", "already_discounted", "remizo_free_product"]
          },
          "description": "no_promo: excluded from discounts; no_points: excluded from points base; remizo_free_product: line added by the POS following a free_product action (ignored on re-evaluation)."
        },
        "attributes": {
          "type": "object",
          "description": "Retailer-specific attributes (size, color, supplier...). Reachable by article_attribute criteria.",
          "additionalProperties": { "type": ["string", "number", "boolean"] }
        }
      }
    },
    "Payment": {
      "type": "object",
      "additionalProperties": false,
      "required": ["method", "amountCents"],
      "properties": {
        "method": { "$ref": "#/$defs/PaymentMethod" },
        "amountCents": { "$ref": "#/$defs/Cents" },
        "reference": { "type": "string", "maxLength": 128, "description": "Authorization code, voucher number, or Remizo reservation id when method = points." },
        "brand": { "type": "string", "maxLength": 32, "description": "Card scheme or wallet brand (visa, cb, applepay...)." }
      }
    },
    "PaymentMethod": {
      "type": "string",
      "enum": ["cash", "card", "check", "voucher", "gift_card", "points", "mobile", "transfer", "credit", "other"]
    },
    "PresentedCoupon": {
      "type": "object",
      "additionalProperties": false,
      "required": ["code"],
      "properties": {
        "code": { "type": "string", "minLength": 1, "maxLength": 64, "description": "Coupon code as scanned/typed (EAN-13, QR payload or short code)." },
        "source": { "type": "string", "enum": ["scanned", "typed", "app", "auto"], "default": "scanned" }
      }
    },
    "Options": {
      "type": "object",
      "additionalProperties": false,
      "description": "Per-call behaviour switches. Defaults come from workspace settings.",
      "properties": {
        "coherenceCheck": { "type": "boolean", "description": "Emit a warning when sum(lines) != totals.grossCents. Never blocks." },
        "explain": { "type": "boolean", "default": true, "description": "Include rejected offers with reasons in the result." },
        "offerIds": {
          "type": "array",
          "items": { "type": "string", "format": "uuid" },
          "description": "Simulator only: restrict evaluation to these offers (draft versions allowed when called through the hub simulator)."
        },
        "pointsBurn": {
          "type": "object",
          "additionalProperties": false,
          "description": "Ask the engine to burn counter units as part of this ticket (alternative to a prior /points/reserve).",
          "required": ["counterTypeCode", "amount"],
          "properties": {
            "counterTypeCode": { "type": "string" },
            "amount": { "type": "integer", "minimum": 1 },
            "reservationId": { "type": "string", "format": "uuid", "description": "Existing reservation to confirm at commit." }
          }
        }
      }
    }
  }
}
