> ## Documentation Index
> Fetch the complete documentation index at: https://docs-pos.solya.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Create or replace an automatic promotion rule

> UPSERTS one automatic promotion rule by `id`: there is no separate create and update, and no PATCH. Posting an existing id REPLACES that rule wholesale — every field not in your body is destroyed, including the reward mechanics. The read-modify-write path is `GET /v1/promotions/{id}/rule` (the directory reads carry no mechanics): read the live rule there, change the one field you were asked to change, and send the whole object back. If that read 404s (the promotion carries no configured rule) STOP and ask; do not reconstruct plausible values to satisfy the schema, as that silently overwrites a live pricing rule. Pick the `type` by the mechanic you want: `bogo` (buy `buyQuantity` of `buySkuId`, get `getQuantity` of `getSkuId` at `getDiscountBps` off — 10000 bps makes it free), `threshold` (once `skuId` reaches `minQuantity` units, a percent or flat `reward` comes off that SKU's subtotal) or `bundle` (buy the `components` together and pay `bundlePriceCents`). Money is integer CENTS and percentages are BASIS POINTS (1000 = 10%). `validFrom`/`validUntil` are inclusive ISO-8601 instants (`Z` or a UTC offset) and `validUntil` must not precede `validFrom`. Use this for an always-on offer any matching basket earns; use `POST /v1/coupons` instead when the reduction must be unlocked by a code the customer presents.



## OpenAPI

````yaml /openapi.json post /v1/promotions
openapi: 3.0.3
info:
  title: Solya POS API
  version: 1.0.0
  description: >-
    The Solya POS backend HTTP surface. Every documented operation is
    agent-ready: it carries an `operationId`, an agent-facing `description`, the
    `pos.*` scopes it enforces (`x-required-permissions`) and an `x-agent-tier`.
    Success responses return the payload as raw JSON; failures return the
    `ErrorResponse` envelope (`{ error: { code, message, statusCode } }`).
servers:
  - url: /
    description: The backend, relative to its deployed origin.
security: []
paths:
  /v1/promotions:
    post:
      tags:
        - Promotions
      summary: Create or replace an automatic promotion rule
      description: >-
        UPSERTS one automatic promotion rule by `id`: there is no separate
        create and update, and no PATCH. Posting an existing id REPLACES that
        rule wholesale — every field not in your body is destroyed, including
        the reward mechanics. The read-modify-write path is `GET
        /v1/promotions/{id}/rule` (the directory reads carry no mechanics): read
        the live rule there, change the one field you were asked to change, and
        send the whole object back. If that read 404s (the promotion carries no
        configured rule) STOP and ask; do not reconstruct plausible values to
        satisfy the schema, as that silently overwrites a live pricing rule.
        Pick the `type` by the mechanic you want: `bogo` (buy `buyQuantity` of
        `buySkuId`, get `getQuantity` of `getSkuId` at `getDiscountBps` off —
        10000 bps makes it free), `threshold` (once `skuId` reaches
        `minQuantity` units, a percent or flat `reward` comes off that SKU's
        subtotal) or `bundle` (buy the `components` together and pay
        `bundlePriceCents`). Money is integer CENTS and percentages are BASIS
        POINTS (1000 = 10%). `validFrom`/`validUntil` are inclusive ISO-8601
        instants (`Z` or a UTC offset) and `validUntil` must not precede
        `validFrom`. Use this for an always-on offer any matching basket earns;
        use `POST /v1/coupons` instead when the reduction must be unlocked by a
        code the customer presents.
      operationId: setPromotion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  properties:
                    id:
                      type: string
                      minLength: 1
                    validFrom:
                      type: string
                      format: date-time
                      pattern: >-
                        ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                    validUntil:
                      type: string
                      format: date-time
                      pattern: >-
                        ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                    type:
                      type: string
                      enum:
                        - bogo
                    buySkuId:
                      type: string
                      pattern: ^[0-9a-f]{64}$
                    buyQuantity:
                      type: integer
                      minimum: 0
                      exclusiveMinimum: true
                      maximum: 9007199254740991
                    getSkuId:
                      type: string
                      pattern: ^[0-9a-f]{64}$
                    getQuantity:
                      type: integer
                      minimum: 0
                      exclusiveMinimum: true
                      maximum: 9007199254740991
                    getDiscountBps:
                      type: integer
                      minimum: 1
                      maximum: 10000
                  required:
                    - id
                    - validFrom
                    - validUntil
                    - type
                    - buySkuId
                    - buyQuantity
                    - getSkuId
                    - getQuantity
                    - getDiscountBps
                - type: object
                  properties:
                    id:
                      type: string
                      minLength: 1
                    validFrom:
                      type: string
                      format: date-time
                      pattern: >-
                        ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                    validUntil:
                      type: string
                      format: date-time
                      pattern: >-
                        ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                    type:
                      type: string
                      enum:
                        - threshold
                    skuId:
                      type: string
                      pattern: ^[0-9a-f]{64}$
                    minQuantity:
                      type: integer
                      minimum: 0
                      exclusiveMinimum: true
                      maximum: 9007199254740991
                    reward:
                      oneOf:
                        - type: object
                          properties:
                            basis:
                              type: string
                              enum:
                                - percent
                            percentBps:
                              type: integer
                              minimum: 1
                              maximum: 10000
                          required:
                            - basis
                            - percentBps
                        - type: object
                          properties:
                            basis:
                              type: string
                              enum:
                                - amount
                            amountCents:
                              type: integer
                              minimum: 0
                              exclusiveMinimum: true
                              maximum: 9007199254740991
                          required:
                            - basis
                            - amountCents
                  required:
                    - id
                    - validFrom
                    - validUntil
                    - type
                    - skuId
                    - minQuantity
                    - reward
                - type: object
                  properties:
                    id:
                      type: string
                      minLength: 1
                    validFrom:
                      type: string
                      format: date-time
                      pattern: >-
                        ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                    validUntil:
                      type: string
                      format: date-time
                      pattern: >-
                        ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                    type:
                      type: string
                      enum:
                        - bundle
                    components:
                      minItems: 2
                      type: array
                      items:
                        type: object
                        properties:
                          skuId:
                            type: string
                            pattern: ^[0-9a-f]{64}$
                          quantity:
                            type: integer
                            minimum: 0
                            exclusiveMinimum: true
                            maximum: 9007199254740991
                        required:
                          - skuId
                          - quantity
                    bundlePriceCents:
                      type: integer
                      minimum: 0
                      maximum: 9007199254740991
                  required:
                    - id
                    - validFrom
                    - validUntil
                    - type
                    - components
                    - bundlePriceCents
              example:
                id: promo-bogo-tshirt-cap
                type: bogo
                buySkuId: >-
                  2f0a30932a471a86d885382399c476bc17fde6e613c42a73e2cf6a760fe4f774
                buyQuantity: 2
                getSkuId: >-
                  9c1d4b7e0a3f6852cd41e0b7a26f5d98314c7ae60b2f9d51a83c604e7fb21d3a
                getQuantity: 1
                getDiscountBps: 10000
                validFrom: '2026-01-01T00:00:00.000Z'
                validUntil: '2026-12-31T00:00:00.000Z'
      responses:
        '201':
          description: >-
            The persisted rule, echoed back with its validity window as ISO
            instants. The example shows a `bogo` rule; the `threshold` shape is
            on `evaluatePromotions` and the `bundle` shape on `updatePromotion`.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      id:
                        type: string
                        minLength: 1
                      validFrom:
                        type: string
                        format: date-time
                        pattern: >-
                          ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                      validUntil:
                        type: string
                        format: date-time
                        pattern: >-
                          ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                      type:
                        type: string
                        enum:
                          - bogo
                      buySkuId:
                        type: string
                        pattern: ^[0-9a-f]{64}$
                      buyQuantity:
                        type: integer
                        minimum: 0
                        exclusiveMinimum: true
                        maximum: 9007199254740991
                      getSkuId:
                        type: string
                        pattern: ^[0-9a-f]{64}$
                      getQuantity:
                        type: integer
                        minimum: 0
                        exclusiveMinimum: true
                        maximum: 9007199254740991
                      getDiscountBps:
                        type: integer
                        minimum: 1
                        maximum: 10000
                    required:
                      - id
                      - validFrom
                      - validUntil
                      - type
                      - buySkuId
                      - buyQuantity
                      - getSkuId
                      - getQuantity
                      - getDiscountBps
                    additionalProperties: false
                  - type: object
                    properties:
                      id:
                        type: string
                        minLength: 1
                      validFrom:
                        type: string
                        format: date-time
                        pattern: >-
                          ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                      validUntil:
                        type: string
                        format: date-time
                        pattern: >-
                          ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                      type:
                        type: string
                        enum:
                          - threshold
                      skuId:
                        type: string
                        pattern: ^[0-9a-f]{64}$
                      minQuantity:
                        type: integer
                        minimum: 0
                        exclusiveMinimum: true
                        maximum: 9007199254740991
                      reward:
                        oneOf:
                          - type: object
                            properties:
                              basis:
                                type: string
                                enum:
                                  - percent
                              percentBps:
                                type: integer
                                minimum: 1
                                maximum: 10000
                            required:
                              - basis
                              - percentBps
                            additionalProperties: false
                          - type: object
                            properties:
                              basis:
                                type: string
                                enum:
                                  - amount
                              amountCents:
                                type: integer
                                minimum: 0
                                exclusiveMinimum: true
                                maximum: 9007199254740991
                            required:
                              - basis
                              - amountCents
                            additionalProperties: false
                    required:
                      - id
                      - validFrom
                      - validUntil
                      - type
                      - skuId
                      - minQuantity
                      - reward
                    additionalProperties: false
                  - type: object
                    properties:
                      id:
                        type: string
                        minLength: 1
                      validFrom:
                        type: string
                        format: date-time
                        pattern: >-
                          ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                      validUntil:
                        type: string
                        format: date-time
                        pattern: >-
                          ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
                      type:
                        type: string
                        enum:
                          - bundle
                      components:
                        minItems: 2
                        type: array
                        items:
                          type: object
                          properties:
                            skuId:
                              type: string
                              pattern: ^[0-9a-f]{64}$
                            quantity:
                              type: integer
                              minimum: 0
                              exclusiveMinimum: true
                              maximum: 9007199254740991
                          required:
                            - skuId
                            - quantity
                          additionalProperties: false
                      bundlePriceCents:
                        type: integer
                        minimum: 0
                        maximum: 9007199254740991
                    required:
                      - id
                      - validFrom
                      - validUntil
                      - type
                      - components
                      - bundlePriceCents
                    additionalProperties: false
                description: >-
                  The persisted rule, echoed back with its validity window as
                  ISO instants. The example shows a `bogo` rule; the `threshold`
                  shape is on `evaluatePromotions` and the `bundle` shape on
                  `updatePromotion`.
                example:
                  id: promo-bogo-tshirt-cap
                  type: bogo
                  buySkuId: >-
                    2f0a30932a471a86d885382399c476bc17fde6e613c42a73e2cf6a760fe4f774
                  buyQuantity: 2
                  getSkuId: >-
                    9c1d4b7e0a3f6852cd41e0b7a26f5d98314c7ae60b2f9d51a83c604e7fb21d3a
                  getQuantity: 1
                  getDiscountBps: 10000
                  validFrom: '2026-01-01T00:00:00.000Z'
                  validUntil: '2026-12-31T00:00:00.000Z'
        '400':
          description: >-
            The request failed schema validation; `error.fieldErrors` lists the
            fields.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationErrorResponse'
        '401':
          description: No valid credential was presented — send a bearer token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: The actor is authenticated but lacks the required `pos.*` scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: An unexpected server error — safe to retry idempotent requests.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
      security:
        - bearerAuth: []
components:
  schemas:
    ValidationErrorResponse:
      type: object
      required:
        - error
      additionalProperties: false
      description: >-
        A `VALIDATION_FAILED` envelope carrying the offending fields in
        `fieldErrors`.
      properties:
        error:
          type: object
          required:
            - code
            - message
            - statusCode
          additionalProperties: false
          properties:
            code:
              type: string
              enum:
                - VALIDATION_FAILED
            message:
              type: string
            statusCode:
              type: integer
            fieldErrors:
              type: array
              description: >-
                One entry per rejected field: the field path and why it was
                rejected.
              items:
                type: object
                required:
                  - field
                  - message
                additionalProperties: false
                properties:
                  field:
                    type: string
                    description: Dot-path of the offending field.
                  message:
                    type: string
                    description: Why the field was rejected.
    ErrorResponse:
      type: object
      required:
        - error
      additionalProperties: false
      description: The uniform failure envelope every non-2xx response returns.
      properties:
        error:
          type: object
          required:
            - code
            - message
            - statusCode
          additionalProperties: false
          properties:
            code:
              type: string
              enum:
                - VALIDATION_FAILED
                - UNAUTHORIZED
                - FORBIDDEN
                - NOT_FOUND
                - CONFLICT
                - BUSINESS_RULE_VIOLATION
                - INTERNAL_ERROR
              description: >-
                Machine-readable kernel `ResultCode` — branch on this, not on
                `message`.
            message:
              type: string
              description: >-
                Human-readable explanation. Safe to surface; never leaks server
                internals.
            statusCode:
              type: integer
              description: >-
                The HTTP status, mirrored into the body so a client need not
                read headers.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        `Authorization: Bearer <token>`. Accepts EITHER a Keycloak access token
        (scopes-in-token) OR an opaque POS session token; both resolve to the
        same `pos.*` scope vocabulary the route guards enforce.

````