POS Returns
DRE supports mixed-basket transactions — a single /pos/v2/evaluate call that contains both sale lines (positive quantity) and return/refund lines (negative quantity). This page covers the wire contract, response format, anti-fraud guards, and the shared-responsibility boundary between the engine and the POS or ERP system.
How Mixed Baskets Work
The engine distinguishes lines by the sign of quantity:
| Quantity sign | Meaning | Effect on evaluation |
|---|---|---|
quantity > 0 | Sale line | Eligible for promotions; contributes to budget consumption, coupons, loyalty, and killer-feature analytics |
quantity < 0 | Return / refund line | Echoed verbatim; promotions do not apply to return lines |
quantity = 0 | Invalid | Rejected with HTTP 400 before the engine runs |
Return lines are echoed verbatim in the response — they do not traverse the evaluation pipeline. They are split out before evaluation (_pipelineItems = sale lines only) and re-merged into the response afterward. Their discounts array is empty and they do not affect promotion matching, budgets, coupons, loyalty, or side effects.
Request Format
The request shape is identical to a standard /pos/v2/evaluate call. Return lines are expressed by providing a negative quantity:
POST /pos/v2/evaluate
Content-Type: application/json
Authorization: Bearer <token>
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{
"articleNumber": "ART-1001",
"quantity": 2,
"unitPrice": 100.00,
"ean": "4001234567890"
},
{
"articleNumber": "ART-1002",
"quantity": -1,
"unitPrice": 50.00,
"ean": "4009876543210"
}
]
}
}
The example above encodes: 2 units of ART-1001 sold at €100 each (sale), and 1 unit of ART-1002 returned at €50 (refund).
Response Format
Return lines are echoed verbatim
Each return line appears in lineItems[] at its original position with:
quantity.valuenegative (as submitted)lineTotal.valuenegative (unitPrice × quantity)lineNet.valuenegativediscounts: []— no discounts appliedaffectedItems— empty (not matched by any promotion)
New totals fields: saleSubtotal and returnSubtotal
When the basket contains at least one return line, totals includes two additional fields:
| Field | Type | Value |
|---|---|---|
saleSubtotal | Money | Sum of unitPrice × quantity for all sale lines (positive). Before discounts. |
returnSubtotal | Money | Sum of unitPrice × quantity for all return lines (negative). |
grandTotal may be negative for net-refund baskets. It is no longer clamped to zero.
Full example response
Mixed basket: ART-1001 ×2 @ €100 (sale), ART-1002 ×−1 @ €50 (return). Assume a 15% promo applies to ART-1001.
{
"minorVersion": 8,
"requestId": "01J4QKHE12FT9A3EXAMPLE",
"meta": {
"source": "btp",
"tenantId": "default",
"evaluatedAt": "2026-06-03T10:00:00Z",
"isSimulation": false,
"header": {
"transactionId": "TX-2026-06-03-00042",
"transactionCounter": 1
}
},
"lineItems": [
{
"lineReference": "1",
"articleNumber": "ART-1001",
"quantity": { "value": 2, "unit": "PCE" },
"unitPrice": { "value": 100.00, "currency": "EUR" },
"lineTotal": { "value": 200.00, "currency": "EUR" },
"lineDiscount":{ "value": 30.00, "currency": "EUR" },
"lineNet": { "value": 170.00, "currency": "EUR" },
"discounts": [
{
"promotionId": "promo-abc-001",
"promotionName": "Summer 15% off",
"promotionType": "ARTICLE",
"discountValue": 15,
"discountAmount": { "value": 30.00, "currency": "EUR" },
"couponCode": null,
"triggeredByCoupon": false
}
]
},
{
"lineReference": "2",
"articleNumber": "ART-1002",
"quantity": { "value": -1, "unit": "PCE" },
"unitPrice": { "value": 50.00, "currency": "EUR" },
"lineTotal": { "value": -50.00, "currency": "EUR" },
"lineDiscount":{ "value": 0.00, "currency": "EUR" },
"lineNet": { "value": -50.00, "currency": "EUR" },
"discounts": []
}
],
"totals": {
"subtotal": { "value": 150.00, "currency": "EUR" },
"discount": { "value": 30.00, "currency": "EUR" },
"grandTotal": { "value": 120.00, "currency": "EUR" },
"saleSubtotal": { "value": 200.00, "currency": "EUR" },
"returnSubtotal": { "value": -50.00, "currency": "EUR" }
},
"recommendations": [],
"appliedCoupons": [],
"invalidCoupons": [],
"budgetLimitedPromotions": []
}
Note: totals.subtotal = saleSubtotal + returnSubtotal (150 = 200 + (−50)). grandTotal = subtotal − discount (120 = 150 − 30).
Minor Version
Mixed-basket support was introduced in minorVersion: 6 (evaluate/simulate). Clients that conditionally render saleSubtotal/returnSubtotal should gate on minorVersion >= 6.
Anti-Fraud Guards
The guards run before promotions are applied. The zero-quantity and max-absolute-quantity checks return HTTP 400; the ratio cap and grand-total floor return HTTP 422 using the standard RFC 7807 error envelope.
| Guard | Trigger condition | HTTP | details[0].message |
|---|---|---|---|
| Zero quantity | quantity === 0 on any line | 400 | "Item at index N must have a non-zero numeric quantity" |
| Max absolute quantity | abs(quantity) > _MAX_LINE_QTY (default 9999, env DRE_MAX_LINE_QTY) | 400 | "quantity … exceeds maximum allowed value" |
| Return-to-sale ratio cap | saleSubtotal > 0 and abs(returnSubtotal) / saleSubtotal > 2.0 | 422 | "Return-to-sale ratio exceeds the allowed cap (2×)." |
| Grand total floor | grandTotal < -10000 | 422 | "Grand total is below the allowed floor (-10000)." |
The ratio cap and grand-total floor run before promotions are applied, after the basket has been split into sale and return lines.
Pure-return baskets (all lines negative)
A basket where every line has negative quantity — saleSubtotal = 0 — is valid. The ratio cap is skipped (division by zero is avoided; the check requires saleSubtotal > 0). Such baskets are bounded only by the grand-total floor.
In a pure-return basket, promotions produce no discounts because there are no sale lines to match. The response is structurally identical to the mixed-basket format; saleSubtotal is 0.00 and returnSubtotal is negative.
What the guards do NOT catch
The guards enforce mathematical limits. They do not verify article returnability, cross-reference original sales, or validate whether the customer actually paid the stated unitPrice. That responsibility belongs to the POS or ERP system — see the shared-responsibility boundary below.
Shared-Responsibility Boundary
The engine echoes the POS-supplied
unitPriceunverified. Refund-amount correctness — whether the customer actually paid this price, whether the article is returnable, and quantity validation against the original sale — is the responsibility of the POS or ERP system, not the engine.
This boundary is intentional. DRE is a promotion evaluation service, not a receipt-management or returns-authorisation system. The engine trusts the basket it receives.
Pure-Return Skip-Confirm
Baskets where every line has negative quantity should not be sent to
/confirm— a confirm must reference at least one applied promotion, so a pure-return basket is rejected with HTTP 422. Only mixed baskets (sale + return) use/confirmnormally; return lines carry zero discount so body-hash, DISCOUNT_MISMATCH, and budget all behave correctly.
If a pure-return basket is inadvertently sent to /confirm, the engine returns HTTP 422 (the confirm request must contain at least one applied promotion, and a pure-return basket has none). This is not a bug — it is the correct response for a basket that yielded no discounts and therefore has no side effects to commit.
Budget, Coupon, and Loyalty Behaviour
Return lines contribute zero to:
- Budget consumption
- Coupon redemption counts
- Loyalty point accrual
- Killer-feature analytics (threshold-gap, progressive nudges, savings summary, near-miss, A/B testing)
Promotion matching runs on sale lines only. A promotion that would otherwise match a return line's article does not fire.
OpenAPI Reference
The saleSubtotal and returnSubtotal fields are present in the OpenAPI spec under the TotalsV2 schema.
The negative-quantity semantics are described in both the BasketItem.quantity field description and the LineItemV2.quantity field description in the same spec.
Related pages
- POS endpoints — the full
/posREST surface. - Promotion scenarios & actions — what each promotion type produces in the response.
- Evaluate → Confirm lifecycle — how
evaluateandconfirmfit together across a transaction.