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

# Basket rules

> What the REKOM backend may change in the basket, how discounts are represented (discount code, note lines, receipt footer), rounding, how existing discounts are kept or swapped, and what OnlinePOS validates.

## Scope of changes in Phase 2

<Check>
  **Agreed scope, unchanged from Version 1 §7 (28 Aug / 4 Sep 2026).** Phase 2 keeps
  every operation Version 1 allowed; nothing has been removed. Where a row says
  "REKOM practice", it describes how REKOM intends to use the operation, not a
  restriction on it.
</Check>

| Operation | Phase 2 | How it is expressed (directional names) |
| - | - | - |
| Amount discount on a line | Allowed | `lineDiscountType: "amount"`, `lineDiscountAmount`, text, discount code |
| Percentage discount on a line | Allowed | `lineDiscountType: "percent"`, `lineDiscountPercent`, computed `lineDiscountAmount`, text, discount code |
| Update a line's price | Allowed | `overrideUnitPrice` on the line. REKOM practice: express a member price as an amount discount with text "Member price" instead, so the benefit shows up as loyalty discount data on the receipt and in POS |
| Add product lines | Allowed; the product must exist on the venue | New line with `addedByLoyalty: true`, `productId`, `quantity`, `unitPrice` |
| Remove product lines | Allowed | Line omitted from the returned `lines` |
| Split a line | Allowed | Two or more lines with `splitFromLineId` set to the original `lineId`; quantities add up to the original. REKOM practice: a partial-quantity benefit ("1 of 3 free") as an amount discount on the whole line, so no split is needed |
| Order-level discount, amount or percent | Allowed; REKOM supplies the split onto lines (*3 Sep 2026*) | Entry in `orderDiscounts` with text and discount code, plus `orderDiscountAllocation` on each eligible line. Shown at the bottom of the receipt; see [below](#order-level-discounts-and-the-legal-discount-flag) |
| Existing discounts on the basket (staff discount, price discount on a product, order discount) | REKOM decides: keep, or swap for a larger loyalty discount | Existing-discount fields changed or cleared on the returned line; see [Existing discounts](#existing-discounts-rekom-decides-what-is-swapped) |
| VAT, product receipt text | Not touched | Echoed as received |

<Note>
  **Directional.** Field names, enum values, headers and JSON structures on this page
  show *which* information is exchanged, not the final names. None of them has been
  aligned with OnlinePOS yet. REKOM will adopt whatever OnlinePOS has by default for
  the order object and its webhooks, and does not require any of these specific names.
</Note>

## Line model (directional)

The request body is **OnlinePOS's own basket object, exactly as OnlinePOS has it**,
including the discounts already on it. REKOM modifies that object per its loyalty
model and returns it; OnlinePOS applies what comes back. The tables below are
REKOM's illustration of the information in that object, grouped by **who sets it**.

### Set by OnlinePOS (input)

Identity and quantity fields are echoed verbatim on every line REKOM keeps. The
existing-discount fields are input: REKOM may change or clear them when it swaps
in a loyalty discount.

| Field | Type | Notes |
| - | - | - |
| `lineId` | string | Unique within the basket |
| `productId` | string | Venue-level product id, as in the REST transaction API (`product_id`) |
| `productMasterId` | string, optional | Cross-venue master id (`product_master_id`) |
| `productGroupId` | string, optional | Product group |
| `ean` | string, optional | `product_ean` |
| `name` | string | Receipt text, informational only |
| `quantity` | number | Units on the line |
| `unitPrice` | integer | Minor units, before any discount |
| `originalLinePrice` | integer | `quantity × unitPrice` |
| `parentLineId` | string, optional | For option / modifier lines that belong to a parent product |
| `noDiscount` | boolean, **mandatory** | `true` when the product may not legally be discounted (e.g. cigarettes). Mirrors the product's `no_discount` setting. REKOM needs it to split order-level discounts correctly (*3 Sep 2026*) |
| `noPercentageDiscount` | boolean, **mandatory** | Product may not receive percentage discounts. Mirrors `no_percentage_discount` |
| `existingLineDiscountType` | `none` / `amount` / `percent` | Discount already on the line before the call (staff discount, product price discount). REKOM may keep it or swap it for a loyalty discount |
| `existingLineDiscountAmount` | integer | Minor units |
| `existingLineDiscountPercent` | number, optional | When type is `percent` |
| `existingLineDiscountText` | string, optional | |
| `existingLineDiscountCampaignId` | string, optional | |
| `orderDiscountAllocation[]` | array | Share of each existing order discount allocated to this line: `orderDiscountId`, `orderDiscountAllocationAmount`. REKOM may keep, change or remove these and appends allocations for order discounts it adds |

### Set by REKOM (only on `applied`)

| Field | Type | Notes |
| - | - | - |
| `lineDiscountType` | `none` / `amount` / `percent` | `none` on lines REKOM leaves untouched |
| `lineDiscountPercent` | number | Required when type is `percent`; two decimals |
| `lineDiscountAmount` | integer | Minor units; always present (0 when `none`) |
| `lineDiscountText` | string, ≤ 40 chars | Printed on the note line, e.g. "Member price", "Members: 2 for 1" |
| `showLineDiscountText` | boolean | Whether the note line is printed |
| `lineDiscountCode` | string | Required when type is not `none`; the predefined catch-all loyalty discount code, see [Discount code](#discount-code) |
| `overrideUnitPrice` | integer, optional | New unit price when REKOM updates a line's price |
| `addedByLoyalty` | boolean | `true` on lines REKOM added; the product must exist on the venue |
| `splitFromLineId` | string, optional | On lines produced by splitting an original line |
| `orderDiscountAllocation[]` | array | Existing allocations kept, changed or removed per REKOM's rules; loyalty allocations appended |
| `orderLinePrice` | integer | Final revenue value of the line, see identities below |

### Arithmetic identities

For every line:

```
linePrice      = quantity × (overrideUnitPrice if set, otherwise unitPrice)
orderLinePrice = linePrice
               − existingLineDiscountAmount
               − lineDiscountAmount
               − Σ orderDiscountAllocationAmount
orderLinePrice ≥ 0
```

For the basket:

```
totals.total                = Σ orderLinePrice over the returned lines
totals.orderDiscountTotal   = Σ over all lines of Σ orderDiscountAllocationAmount
for each orderDiscountId:     Σ orderDiscountAllocationAmount = that order discount's amount
totals.loyaltyDiscountTotal = Σ lineDiscountAmount + Σ amount of the order discounts REKOM added
totals.total                ≥ 0
```

Lines the REKOM backend does not change are returned exactly as received, with
`lineDiscountType: "none"` and `lineDiscountAmount: 0`. A response with status
`applied` changes the basket in at least one way (a discount, a price update, an
added, removed or split line); if the calculation changes nothing, the response is
`unchanged`.

## Order-level discounts and the legal-discount flag

<Check>
  **Agreed 3 Sep 2026 (Google Doc comments on V1 §8).** Order-level discounts are
  allowed, and REKOM does the splitting onto lines. OnlinePOS's answer: to register
  revenue correctly, all order discounts have to be split on the separate lines, and
  because some products, cigarettes for example, are not legally allowed to be
  discounted, the partner must do that split correctly. OnlinePOS places order
  discounts visually at the bottom of the receipt, as it already does for discount
  campaigns.
</Check>

For REKOM to do that split correctly, OnlinePOS has to tell it **per line** whether
the product may legally be discounted. That is what the two flags are for, and why
they are mandatory on every line. The names are directional; the information is
what is required, under whatever name OnlinePOS already has for it.

| Rule | Detail | Status |
| - | - | - |
| `noDiscount` | Mandatory on every line. `true` when the product may not legally be discounted; mirrors the product's `no_discount` setting in OnlinePOS. A request without the field is rejected with `400 invalid_request`. | Required by REKOM (follows from 3 Sep 2026) |
| `noPercentageDiscount` | Mandatory on every line; mirrors `no_percentage_discount`. | Required by REKOM |
| Order discount entry | REKOM adds an entry to `orderDiscounts` with `type`, `amount` (and `percent`), `text` and the discount code. Order discounts already present are kept, changed or removed per REKOM's rules (see below). | Agreed (V1 §8 structure) |
| Splitting rule | The amount is spread over the eligible lines only (`noDiscount: false`), in proportion to `linePrice`, rounded per line with the remainder placed on the largest eligible line so the allocations add up to the amount. Excluded lines get no allocation. | Proposed |
| Receipt | The order discount is shown at the bottom of the receipt (*3 Sep 2026*; free-text field, informational, *7 Oct 2026*). Its revenue effect is carried by the per-line allocations, which OnlinePOS validates. | Agreed |
| Open | Whether other legal restrictions exist that the product flag does not cover, and that option lines carry the flag too. | [Open point O12](/reference/open-points) |

## Discount code

<Check>
  **Agreed, follow-up after 7 Oct 2026.** Loyalty discounts carry **no campaign ID**.
  Every loyalty discount instead carries a **predefined, semantic string** that
  identifies the catch-all third-party-loyalty discount type in POS
  (`lineDiscountCode` on lines, `discountCode` on order discounts). This is the
  "general system campaign for third-party loyalty" variant of the 6 Oct 2026
  assumption: no per-venue campaign is created, and nothing is provisioned through
  the REST API.
</Check>

| Rule | Detail |
| - | - |
| Fields | `lineDiscountCode`, required on every line where `lineDiscountType` is not `none`; `discountCode` on every order discount REKOM adds. Absent otherwise |
| Value | One predefined string for the whole integration, defined by OnlinePOS. REKOM proposes `loyalty` until OnlinePOS names it ([open point O2](/reference/open-points)). |
| Scope | The same code for amount and percentage discounts: the discount type carries the arithmetic, the code carries the "this is loyalty" meaning |
| POS side | Maps the code to its built-in loyalty discount type for the note line, receipt reproduction and its own discount categorisation |
| Validation | OnlinePOS rejects a response whose code is not the agreed string (`failureReason: invalid_response`) |
| Reporting | OnlinePOS sees one discount type; the breakdown per benefit lives in REKOM, joined on the calculation GUID |

Staff discounts that already exist on a line or order keep their campaign reference
(`existingLineDiscountCampaignId`, `orderDiscounts[].campaignId`); that is
OnlinePOS's existing discount system and is untouched.

## Receipt rules

<Check>**Agreed 17 Aug, 3 Sep and 6 Oct 2026.**</Check>

* A loyalty line discount prints as a **note line** directly under the product, with
  the discount text and the monetary value shown separately. The product's own
  receipt text is never changed.
* **No new print layouts.** Note lines use the existing mechanism.
* A discount is **dedicated POS discount data**, never a free-text product with
  price zero (*3 Sep 2026*).
* Order-level discounts are shown at the **bottom of the receipt**, as OnlinePOS does
  for discount campaigns (*3 Sep 2026*), through a free-text field that is
  informational and not part of reporting (*7 Oct 2026*). REKOM may fill
  `receiptFooterText` (≤ 200 characters) for this, e.g. "Members 10 %: −13,00 kr."
* `memberLabel` (≤ 40 characters, e.g. "REKOM Member") may be shown on the receipt
  header or footer if OnlinePOS has a place for it; otherwise ignored.
* Nothing from the response is shown on the customer display or to staff before
  payment beyond the updated basket total, since the call runs during the payment
  motion.

## Rounding

<Info>**Proposed — for OnlinePOS confirmation.**</Info>

The REKOM backend computes every amount. For a percentage discount on a line:

```
lineDiscountAmount = round_half_up(linePrice × lineDiscountPercent / 100)
```

Rounding happens once per line, in minor units; order-discount allocations are
rounded the same way, with the remainder on the largest eligible line. OnlinePOS
does not recompute percentages; it validates the identities above using the
integer amounts in the response. This avoids one-øre disagreements between the two
systems.

## Existing discounts: REKOM decides what is swapped

<Check>
  **REKOM logic.** The basket OnlinePOS sends includes the discounts already on it:
  a staff discount, a price discount on a specific product, an order discount. The
  REKOM backend decides, per line, what stays and what is swapped. If the loyalty
  programme unlocks a larger discount than the one already there, the guest gets the
  loyalty discount instead when the basket comes back; otherwise the existing
  discount stays and REKOM adds nothing. This logic is strictly REKOM's. OnlinePOS
  simply receives the returned basket and applies it, which follows from the 7 Oct
  2026 agreement that the returned basket is authoritative.
</Check>

Consequences:

* A returned line may carry a different existing discount than it was sent with,
  or none, because REKOM swapped it out. OnlinePOS's validation therefore checks
  arithmetic and product identity, not that discount fields are unchanged.
* A swapped-out staff discount no longer prints; the loyalty note line prints in
  its place. Nothing is printed twice for the same line.
* Whether a benefit stacks on top of an existing discount or replaces it is a
  REKOM rule per benefit, not a POS setting. REKOM's default is replace-if-larger.
* The same applies to order discounts already on the basket: REKOM may keep,
  change or remove them and re-allocate accordingly.
* If every eligible line already carries a better discount, the response is
  `unchanged` with reason `all_lines_excluded`.

<Warning>
  **Open — OnlinePOS to confirm ([O4](/reference/open-points)).** OnlinePOS's 6 Oct 2026
  assumption said REKOM follows POS's existing rules for combining discounts. REKOM's
  reading is that the returned basket is applied as-is, including a replaced or removed
  staff discount, and that no POS-side combination rule is involved. OnlinePOS to confirm.
</Warning>

## Flags

| Flag | REKOM behaviour |
| - | - |
| `noDiscount: true` | Line is never discounted, never gets an order-discount allocation and never has its price lowered. Mandatory; mirrors the product's `no_discount` setting (*3 Sep 2026*). |
| `noPercentageDiscount: true` | Percentage benefits are either converted to an amount benefit on this line or skipped, depending on the benefit. Mandatory; mirrors `no_percentage_discount`. |
| `parentLineId` set | Option lines inherit the parent's eligibility; a benefit on the parent does not automatically extend to options unless the benefit says so. |

## Validation in OnlinePOS

OnlinePOS validates the returned basket for (V1 §9, unchanged):

1. Correct schema and the same `calculationId` as the request.
2. Every `productId` exists on the venue, including added and split lines.
3. Identity and quantity fields (`lineId`, `productId`, `quantity`, `unitPrice`, the
   flags) are unchanged on every line REKOM kept; split lines' quantities add up to
   the original line. Discount fields may differ, because REKOM may have swapped an
   existing discount.
4. `lineDiscountCode` / `discountCode` equals the agreed catch-all loyalty discount
   code wherever a loyalty discount is set.
5. No loyalty discount, allocation or price reduction on a `noDiscount` line; no
   percentage discount on a `noPercentageDiscount` line.
6. The sum of `orderDiscountAllocationAmount` per `orderDiscountId` equals the
   stated order discount.
7. The per-line and basket identities above hold; all amounts are integers ≥ 0;
   the basket total equals the sum of `orderLinePrice`; total ≥ 0.

OnlinePOS does not validate REKOM's business logic. A basket that fails validation
is rejected: the original basket is used, the order's loyalty status is set to
`failed` (the sale itself completes), and the sale event carries
`failureReason: invalid_response`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.