Skip to main content

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 signMeaningEffect on evaluation
quantity > 0Sale lineEligible for promotions; contributes to budget consumption, coupons, loyalty, and killer-feature analytics
quantity < 0Return / refund lineEchoed verbatim; promotions do not apply to return lines
quantity = 0InvalidRejected 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.value negative (as submitted)
  • lineTotal.value negative (unitPrice × quantity)
  • lineNet.value negative
  • discounts: [] — no discounts applied
  • affectedItems — 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:

FieldTypeValue
saleSubtotalMoneySum of unitPrice × quantity for all sale lines (positive). Before discounts.
returnSubtotalMoneySum 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.

GuardTrigger conditionHTTPdetails[0].message
Zero quantityquantity === 0 on any line400"Item at index N must have a non-zero numeric quantity"
Max absolute quantityabs(quantity) > _MAX_LINE_QTY (default 9999, env DRE_MAX_LINE_QTY)400"quantity … exceeds maximum allowed value"
Return-to-sale ratio capsaleSubtotal > 0 and abs(returnSubtotal) / saleSubtotal > 2.0422"Return-to-sale ratio exceeds the allowed cap (2×)."
Grand total floorgrandTotal < -10000422"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 unitPrice unverified. 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 /confirm normally; 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.