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.