Security considerations in e-commerce are almost always technical — injection, authz, secrets, rate limits. But the most expensive security issues in a mature retailer will never be technical. They are process vulnerabilities, which appear in the seams between two systems that assume differently about the same thing, and a person behind a counter that has an override button and no data to contradict it.

Commonality of every gap

Before the list, the pattern that ties all ten gaps above together. In every case, an order-time fact — price, conditional cart discount, tender type ratio, unit identity, delivered quantity — is used at return time by an actor that sees this fact as something else: a simple, unconditional, per-order-line thing. Two services disagree about the assumption:

The object System A System B
Price "the paid line price is fixed at €3,000" (order) "this SKU is priced at today's shelf price, €4,500" (POS catalog)
Discount "the discount is a property of the whole cart" (promotion engine) "each line has a fixed price; refund per line" (return flow)
Tender "this order was funded 60% gift card / 40% card" (capture layer) "one order total; refund it to the card" (refund service)
Unit "line 2 is a CAM-X100" — type identity (order line) "matches EAN, condition OK — the same unit" (store associate)
Status "this RMA is still open; I need to refund after receiving the goods" (returns) "took the goods and paid — finished" (POS/OMS)

In all cases, the remedy is the same architectural move: assign the single authoritative source of truth for the contested value, and make every channel — web, POS, CRM, OMS — rely on that source rather than recomputing the value locally. If the value is supposed to live outside SAP Commerce Cloud, then the write-back or reconciliation link is the security control, and its absence is the vulnerability.

This article is a red team of the process. It is written specifically for SAP Commerce Cloud, but technically it is universal. For every gap I distinguish three things that are normally blurred together: what the platform already handles out of the box, what you have to build yourself in custom development, and what is not the platform's job at all — the value or decision that lives in an external system (POS, OMS, loyalty, payment, ERP). Keeping those three apart is the difference between a design that quietly assumes the platform has your back and one that puts a control exactly where the money actually leaks.

Pricing

Shelf price refund, not order-line price

This scenario is utterly primitive. It’s hard to believe that things like this still exist anywhere, but we have to start from the basics and work our way up. Legit promo purchase, same retailer, SKU-scanned return paid out at today's shelf price — almost never surfaces as a named prosecution, because it was a known seam 15–20 years ago and most major POS systems closed it (receipt/card lookup ties the refund to the original capture; no-receipt returns pay out at the lowest recent price, not the current one).

Exploit scenario. Dmitri buys an espresso machine online during a flash sale: catalog base €4,500, a third off, paid €3,000. After two weeks, when the sale is over and the shelf price rises to €4,500, he returns this unit to the flagship store. The store associate scans the SKU; the POS catalog sees that today this SKU is worth €4,500 and refunds the full price to Dmitri's card. He nets +€1,500 for each returned unit — just buy ten units on promo, return ten units at shelf price, +€15,000. The "no-receipt return" variation is just the same vulnerability with an extra policy cover: refund at current price because the order is lost.

One real-world brake to name up front: a linked refund at most PSPs cannot exceed the original €3,000 capture, so this over-refund needs either a restricted unreferenced (blind) credit or — far more commonly — a POS-local standalone return that never sees the capture cap. That POS-local path is exactly the seam this gap lives on.

Mechanism. The order claims "this unit's price is the frozen discount price, €3,000." The POS catalog claims "this SKU is worth however much our price list says." The refund is calculated using a wrong assumption about the unit price because nothing requires the POS system to use the order's frozen price. Once the unit is scanned at the return desk, it loses its "line on ORD-88421" identity forever.

Verdict.

How to interpret the platform verdicts. Every gap carries one or more of three tags — and a missing colour is itself information (no means the platform gives you nothing to build on here):

Modelled by the platform — SAP Commerce Cloud has enough storage or enforcement to be correct right out of the box (or gives you the primitives to be correct).
Left to your custom development — the data exists, but the default behavior or the integration boundary leaves the gap open; closing it is your project's responsibility.
Out-of-scope of the platform — the value or decision is stored in an external system (POS, external OMS, loyalty program, cashback network, BNPL, ERP intercompany accounting, franchise contract). SAP Commerce Cloud can at best provide the ability to reconcile this value.

Wherever the behavior depends on your version, edition (stock accelerator vs. SAP Order Management), or prior customization, we say "verify in your build" instead of making an assertion. Take no verdict below as a substitute for testing your return flow. And these are ten high-value examples, not a canonical or exhaustive set — a short list of the ones we left out is at the end.

/ Everything needed to calculate the refund correctly is recorded by the platform — AbstractOrderEntry persists basePrice, totalPrice, quantity and discounts via discountValues (list of DiscountValue) plus order-level globalDiscountValues, all frozen against the order rather than looked up live. But be precise about the default: the native DefaultReturnService.createRefund(...) sets RefundEntry.amount from the product base price (OrderEntry.basePrice, pre-discount) when no amount is passed — not the discounted totalPrice. In Dmitri's order (base €4,500, paid €3,000) the out-of-the-box refund is €4,500 — the platform's own default leaks the promo. So refunding the paid line price is a matter of what you compute and pass in, not an automatic guarantee; what the platform gives you for free is that the value is frozen to the order, not the catalog. The return domain is otherwise first-class — ReturnRequest, ReturnEntry/RefundEntry, handled through ReturnService (DefaultReturnService), RefundService and, on the OMS side, OmsReturnFacade.

The gap lies at the POS-to-Commerce boundary. The POS must somehow resolve the physical item back to the original order line and pull the paid price — via OCC /orders/{code} REST endpoint, a bespoke returns endpoint or the OMS/RMA layer. If the POS creates a local return instead and only notifies Commerce that "the refund happened", Commerce becomes a passive ledger and the price authority is in the POS. ASM (Assisted Service Module) is the supported mechanism that closes the gap if the associates drive the return process in Commerce instead of POS.

The physical POS, the associate's manual price override, the no-receipt return policy and the third-party OMS that owns the refund tender. SAP Commerce cannot force a foreign POS to respect OrderEntry.totalPrice; it can only provide that via API and record the results.

Detect it. Reconcile refund amount against paid line price:

SELECT {o.code}, {e.entryNumber}, {e.totalPrice} AS paidLine,
       {re.amount} AS refundAmt, {e.product}
FROM   {RefundEntry AS re
        JOIN ReturnEntry AS ren ON {re.pk}={ren.pk}
        JOIN OrderEntry  AS e   ON {ren.orderEntry}={e.pk}
        JOIN Order       AS o   ON {e.order}={o.pk}}
WHERE  {re.amount} > ({e.totalPrice} / {e.quantity})

Any row where the refund amount exceeds the paid line price is a shelf price drift (or a legitimate gross-up of the refund; further investigation is needed). If the refunds are originated from the POS system, the artifact is in the POS journal / SAP CAR audit / OMS refund log and must be cross-reconciled against Commerce order lines by order code. Also inspect the Spring bean behind returnService/refundService and any inbound integration mapping that sets RefundEntry.amount from an external field.

The test. Place an order on an active promotion (paid €3,000, base €4,500), end the promotion, then return one unit through each channel — native ReturnService, the OCC returns API, and the real store POS — and inspect RefundEntry.amount. Note that the OOTB default will hand you €4,500 unless an amount is supplied; the correct target is €3,000. Any channel that silently yields €4,500 is repricing (from catalog, or from the base-price default).

Prevent it. Make the frozen paid line price the single source of price truth: every return path resolves to OrderEntry and refunds the paid-line amount, enforced in a custom refund-amount calculation so it holds regardless of channel and overrides the base-price default. Adopt an RMA-first architecture — online generates a return authorization carrying the paid price and order key; the POS must scan it and call Commerce/OMS to compute the amount rather than present its own. Default no-receipt returns to lowest-of-last-N-days or store credit, with velocity checks. Role-gate and reason-code every price override, and stream it back for the audit trail above.

Cart-discount re-allocation for partial return

The exploit. Bella buys three shirts at €3,000 each. A "3-for-2" promotion discounts the cheapest by €3,000, so she pays €6,000. The engine parks the −€3,000 on line 3. She returns lines 1 and 2 — the two full-price shirts — and keeps line 3. A naive per-line refund pays back €6,000 for the two returned lines, and she keeps a shirt for which she has now paid nothing. The promotion required buying three; she has "bought" one free.

The threshold variant is subtler. Buy A=€6,000 and B=€5,000, subtotal €11,000, "−20% over €10,000" → paid €8,800. Return B. Even if the refund honours B's allocated share (€5,000 − €1,000 = €4,000), the retained cart is now only €6,000 — below the €10,000 threshold — so A should never have kept its −€1,200 discount. It is not recalculated, so Bella keeps A for €4,800 when she should pay €6,000. Free-shipping-over-threshold has the same shape: return items to drop below the threshold, and the waived shipping is never clawed back.

The mechanism. The promotion engine treats the discount as a property of the whole cart, conditional on its composition. The return flow treats each line as having a fixed price with the discount already "baked in," and never re-asks whether the cart still qualifies.

SAP Commerce verdict.

The rule-based promotionengine (Drools-backed RuleBasedPromotion, actions like RuleBasedOrderAdjustTotalAction, evaluated to a PromotionResult) records exactly which promotions fired and their consequences. Discounts persist with allocation — entry-level in discountValues, order-level in globalDiscountValues, apportioned to entries during calculateTotals. The platform stores enough to compute a proportional refund.

The important honesty flag: the OOTB default sets RefundEntry.amount from the product base price (undiscounted), so absent customization the full-line-price leak is the default behaviour — the platform does not allocate the discount to the refund for you. And crucially, the return flow does not re-run the promotion engine against the post-return cart to reallocate a shared discount to the retained lines or claw back a benefit whose threshold is no longer met. (A totals-recalculation primitive does exist — RefundService.createRefundOrderPreview(order) clones the order and recomputes totals — but it does not re-evaluate rule-based promotions against a hypothetical remaining cart.) Whether prior customization changed any of this, verify by test — but don't assume the platform does the conditional recompute; by default it does not.

The business decision of whether promotions are "locked once earned" (a legitimate goodwill policy) versus strictly conditional; and any external OMS or tax engine that owns the refund calculation.

Detect it. Surface partial returns on orders that carried a global discount, then compare the refunded total against the marginal value of the returned items:

SELECT {o.code}, {o.globalDiscountValuesInternal},
       {re.amount} AS refundAmt, {ren.receivedQuantity} AS retQty,
       {e.entryNumber}, {e.totalPrice}
FROM   {ReturnRequest AS rr
        JOIN Order      AS o   ON {rr.order}={o.pk}
        JOIN ReturnEntry AS ren ON {ren.returnRequest}={rr.pk}
        JOIN RefundEntry AS re  ON {re.pk}={ren.pk}
        JOIN OrderEntry  AS e   ON {ren.orderEntry}={e.pk}}
WHERE  {o.globalDiscountValuesInternal} IS NOT NULL

This query only filters to candidates; the flagging is done in analysis. globalDiscountValues is stored serialized, so decode it, then flag any partial return where the refund total exceeds paidTotal − retainedPaidTotal. Confirm from PromotionResult rows that no new evaluation fired on return, and check deliveryCost versus the retained subtotal for un-clawed free shipping.

The test. Trigger 3-for-2 (3 × €3,000, paid €6,000), note which entry holds the −€3,000, return the two full-price lines, and inspect the two RefundEntry.amount values: do they sum to €6,000 (leak — free retained shirt) or €3,000 (correct — promotion dissolved)? Then the threshold case: A=€6,000 + B=€5,000 under "−20% over €10,000," return B, and check whether the refund is €5,000, €4,000, or the economically-correct €2,800 (paid €8,800 minus the €6,000 the retained A should now cost at full price).

Prevent it. The correct primitive is refund = paidTotal(original) − paidTotal(retainedCart after re-running the promotion engine). Implement a custom refund-amount calculation that clones the order minus the returned quantities, runs PromotionEngineService, calls calculateTotals, and diffs. This handles 3-for-2 dissolution, threshold loss, and proportional reallocation in one consistent mechanism. Make free-shipping recovery a toggle in the return process. Above all, make the policy — "recompute promotions at return" versus "earned promotions are honoured" — an explicit, documented decision. The failure mode is having no policy and letting the naive per-line (or base-price) default decide silently.

Non-cash money

Both gaps in this section are, fundamentally, outside SAP Commerce Cloud's native scope, and that is the point. The platform owns the order, the return request, the refund amount, and the card refund follow-on — and roughly there its knowledge ends. Loyalty, cashback, stored-value gift cards, and BNPL all live in external systems. The exploit never happens inside the platform; it happens in the seams.

No clawback of loyalty points and cashback

The exploit. Dario buys a €600 machine online. The loyalty program credits 6,000 points on capture. Within 48 hours he redeems the 6,000 points on a separate small order and consumes the goods. Then he returns the machine for a full €600 refund. The points reversal tries to deduct 6,000 from a balance that now holds 200 — and if the rule is "balance cannot go negative," it deducts 200 and writes off 5,800 points (~€58). Points are fungible and instantly spendable; the return is slow and asynchronous. The window between earn and reversal is the entire attack surface. (A card or portal cashback leg rides along — but see the honesty flag below; that part is usually recovered, on a lag.)

The mechanism. The loyalty system assumes earning is a function of the order-placed event and treats the accrual as final. The commerce/returns system assumes the refund is a function of the order line's monetary value and holds no obligation over the loyalty ledger. Nobody owns "net earned value after settlement." Accrual is computed on the gross order; reversal, if any, is computed against whatever balance happens to remain — not against the original accrual.

SAP Commerce verdict.

Out of the box, the platform does not do this at all — and this is the framing that matters. There is no LoyaltyPointsService, no points ledger, no earn/burn engine in SAP Commerce. Loyalty lives in an external system (SAP Emarsys Loyalty, Talon.One, Antavo, Annex Cloud, or bespoke). Cashback fires at the acquirer/issuer/aggregator (Rakuten, a card issuer) off the transaction event and is governed by their return-detection SLA. SAP Commerce has zero visibility into either.

What the platform does own is the machinery to trigger a reversal at the right moment: the ReturnRequest / RefundEntry flow, the return business process, and payment REFUND_FOLLOW_ON against the original capture. It can emit the event; it cannot maintain the ledger.

The reversal itself is custom integration. Hook the return-process completion (RETURN_COMPLETED) to call the loyalty system's reversal API with the original accrual tied to the specific returned lines — which means storing, at earn time, how many points each order line generated (a custom LoyaltyAccrualEntry linked to AbstractOrderEntry) so partial returns reverse proportionally. The negative-balance policy must be an explicit contract with the vendor. Use idempotency keys so a retryable process step doesn't double-fire.

Two honesty flags. (1) Whether a loyalty balance can go negative is a property of the external vendor's data model, not of SAP Commerce — many products hard-floor at zero by design, which is the root cause; verify per program. (2) Card/portal cashback is usually clawed back automatically by the issuer or held ~60–90 days and reversed by the portal on a return — so it is not a guaranteed permanent loss. The real exposure is the timing gap and any portal that fails to reverse; the durable story here is point fungibility, not cashback.

Detect it. The single highest-signal report joins the loyalty ledger to orders: accruals whose parent order has a completed ReturnRequest but no matching reversal (or a reversal smaller than the accrual). Add a "reversal write-off" report summing reversals that hit the zero-floor — that total is the leak, quantified. For cashback, reconcile the aggregator's monthly clawback statement against your returns register. Inspect the vendor's "allow negative balance" and "return reversal" settings, and check whether return-process even contains a reversal step.

The test. Place an order, confirm points accrued, redeem the full balance on a second order, then return the first order in full. Assert that the ledger shows a reversal equal to the original accrual and the balance goes negative (or a debt/write-off record exists). Repeat with a partial return and assert proportional reversal.

Prevent it. Reverse against the original accrual, not the current balance. Allow negative balances by contract (claw back on next earn) or convert the unrecoverable portion to an explicit cash-recovery record — never silently floor at zero. Better still, delay high-value accruals past the return window: accrue on order but make points redeemable only after the return-eligibility period, or hold a portion "pending." Where the program permits, fire the earn event on net-settled amount rather than order-placed. Run a daily three-way reconciliation: commerce returns ↔ loyalty ledger ↔ payment/cashback settlement.

Non-cash tenders conversion to cash

The exploit. Mara buys €500 of goods, paying €400 with a promotional gift card (issued under a "spend €300 get €100 free" campaign — so €100 of that balance was a promotional bonus) and €100 on her debit card. She returns everything. The return UI sees "order total €500" and refunds €500 to her debit card. Her real outlay was €400 (€300 for the gift card + €100 debit); she receives €500 — a €100 net gain, exactly the promotional bonus, laundered into cash. The gift card, which should have been reinstated as non-cash store credit, evaporated into a bank account.

The BNPL (Buy Now, Pay Later) variant is worse. Mara buys a €900 sofa split as €300 down plus Klarna "Pay in 3" for €600. Klarna has already paid the merchant up front. She returns the sofa and is refunded €900 to her card — while the €600 installment plan stays active and must be separately cancelled or clawed back through Klarna's API. Done wrong, the merchant has refunded out-of-band and still has to unwind Klarna's funding: double exposure on the BNPL leg.

The mechanism. The refund service assumes the order is a single total settled to one tender (the card on file). The capture layer — and the external gift-card or BNPL provider — knows the order was funded by multiple tenders in specific proportions, each with its own reversal semantics. Cash-versus-non-cash tender identity is lost between capture and refund, so the refund collapses a heterogeneous tender mix into homogeneous cash.

Reality check on the card leg. Refunding €500 to a card charged €100 (or, in Gap 1, €4,500 to a card charged €3,000) exceeds the original capture. A linked/referenced credit at most PSPs and under Visa/Mastercard rules cannot exceed the captured amount, so the pure "over-refund to card" needs an unreferenced credit (restricted and flagged) or a POS-local standalone return. Where the excess is a gift-card or BNPL leg refunded to a card, the card-side amount can still sit within the card's own capture — which is precisely why tender identity, not just amount, has to survive into the refund.

SAP Commerce verdict.

Loyalty, stored-value gift cards, and BNPL are all external. SAP Commerce's Voucher/PromotionVoucher/SerialVoucher are discount vouchers, not stored-value instruments — real gift-card capability (balance ledger, activation, reload) is a custom item type plus an external provider (Blackhawk, SVS/Fiserv, Givex). BNPL providers (Klarna/Affirm/Afterpay) integrate as a payment method; the platform has no native awareness that a refund must cancel an installment plan.

Be blunt about the platform weakness: SAP Commerce OOTB has very limited split-payment support. The classic model is one PaymentInfo per order; there is no first-class "list of tenders with amounts" the refund flow natively honours. The data model can represent multiple captures — paymentTransactions is a collection, each PaymentTransaction holding AUTHORIZATION/CAPTURE/REFUND_FOLLOW_ON entries — but the standard refund logic does not fan a refund out across those original transactions in proportion, and RefundEntry carries an amount, not a tender.

So the tender-aware behaviour is entirely custom, and it has three parts. First, persist a structured tender ledger at capture — a custom OrderTenderEntry, or disciplined, tagged PaymentTransactions — recording which provider, which instrument, and how much. Second, read it in the return flow and split each RefundEntry across the original tenders in the original proportions. Third, orchestrate the correct reversal per tender: REFUND_FOLLOW_ON to the specific card capture, re-credit to the external gift-card provider via its API, reverse loyalty via its API, and POST the refund to the BNPL provider so the plan is reduced. The hard invariant on top of all three: non-cash must never refund to cash.

Exact behaviour depends heavily on the PSP. Adyen, for instance, supports refund-per-payment-method at the PSP and can carry tender identity if the integration passes it through — but SAP Commerce won't do the proportional split for you unless coded. Verify per integration.

Detect it. The smoking-gun report joins capture PaymentTransactionEntry rows (by tender/provider) against REFUND_FOLLOW_ON entries for the same order, and flags any order where the refund tender distribution differs from the capture tender distribution — especially refunds-to-card on gift-card or BNPL captures. Cross-check the external gift-card ledger for missing re-credits and the BNPL settlement report for refunded orders whose installment plan was never cancelled (the merchant is still being debited).

The test. Place a 60% gift card / 40% card order, return it in full, and assert 60% re-credited to the gift-card provider (verify the external balance) and 40% REFUND_FOLLOW_ON to the card — not 100% to card. Repeat with a partial return (assert proportional split) and with a BNPL order (assert the plan is cancelled at the provider, verified in their portal).

Prevent it. Make refund-to-original-tender, per-tender, proportional a non-negotiable invariant in the refund service. Model split payment explicitly since OOTB doesn't. Route BNPL refunds through the provider API so the plan is adjusted. Mark promotional gift-card value as non-refundable-to-cash and track the promo-funded portion separately, so "free" value can never be reinstated as more than non-cash store credit. Reconcile daily across commerce refunds ↔ gift-card ledger ↔ PSP/BNPL settlement.

The physical unit

The single structural truth for this section: SAP Commerce Cloud's order line is type-identified (Product / variant / EAN), not instance-identified. Every serialization-based control below is therefore custom, and — because the bundle module actively decomposes a bundle into component order entries — every "must return as a unit" control is custom too.

Substitution under non-serialized inventory

The exploit. SKU CAM-X100 (mirrorless camera body), one EAN, sells online at €1,199 as new, current production year. The same EAN also appears on clearance units of the prior revision at a partner store (€649) and on open-box grey-market units on a marketplace (€520). Marek buys the €1,199 unit online and keeps it. He separately acquires a €520 open-box unit with the same EAN, initiates an online return, and drops that unit. The operator scans the EAN, it matches the ReturnEntry, condition looks acceptable → €1,199 refunded. Marek keeps the new unit. Net loss ~€679 per cycle (€1,199 refund minus the ~€520 value of the substitute the retailer takes back), plus a hidden second loss when the lower-grade unit re-enters sellable stock at full value. The apparel variant is the same trick with one EAN printed across a size run.

The mechanism. The order/fulfillment service assumes the ReturnEntry identifies the thing shipped — but it references only a Product code and a quantity, with no per-instance identity. The operator assumes "scan matches order line + condition OK = same unit." The scan validates product-class membership, not instance provenance.

SAP Commerce verdict.

/ The platform models the product/variant hierarchy (VariantProduct, GenericVariantProduct, ApparelSizeVariantProduct, VariantValueCategory), StockLevel (quantities per warehouse, not identities), and the return graph at order-line + quantity granularity. Note the terminology trap: Unit/UnitModel on a product is the unit of measure, not a serialized physical unit. SAP Commerce core does not track individual serial numbers for standard products. There is no OOTB attribute on OrderEntry/ConsignmentEntry/ReturnEntry for a per-unit serial or IMEI. The returned item's identity is therefore not verifiable by the platform — it can confirm "an item with EAN X, quantity 1" but never "the specific unit dispatched under consignment C." Serialization-adjacent capability lives in other SAP products (S/4HANA serial/batch management, Advanced Track & Trace, EWM handling units), not the commerce order line.

To close it: capture a serial/IMEI/tag at fulfillment (extend ConsignmentEntry, or add a ProductInstance item type linked to consignment and order entry), and add a return-process validation that rejects or holds any ReturnEntry whose scanned serial is not in the set dispatched for that order — blocking the RefundEntry until it passes. Make it mandatory only for a configurable high-value / high-risk product set; don't serialize the whole catalog. Add mandatory condition grading on receipt, and never auto-restock to InStock at full value without a grade.

The physical inspection (is this the exact unit, correct production year, new vs. used), the POS scan, and serial/batch/warranty master data in the ERP.

Where this actually bites. For cameras, phones, and laptops, mature retailers already serial-track and serial-match on return — so the categories the example uses are often already controlled physically. The gap is real inside SAP Commerce's order line (there is no instance identity to reconcile against), and it bites hardest in categories that aren't already serial-tracked: apparel with shared EANs, accessories, mid-value electronics, private label.

Detect it. Find high-value SKUs whose EAN is shared across multiple variants or base products — those are the collision candidates: query Product grouped by ean having count(distinct code) > 1, ranked by price. Then a refund-versus-cost report: cluster RefundEntry.amount on returned lines far above the SKU's weighted-average cost. And a re-injection audit: items marked returned then set back to sellable stock with no condition/grade attribute.

The test. Ship a line for a SKU with EAN E at price P; initiate a return but at receipt scan a different physical unit legitimately carrying EAN E (different batch / open-box). Does the system accept receivedQuantity and authorize the full refund at P with no serial or condition gate? If yes, you are exposed. The blunt audit question: "For any completed high-value return, can you prove the physical instance received equals an instance dispatched under that order? If the answer is 'we scanned the EAN,' the control does not exist."

Prevent it. Serialize at fulfillment for high-value / collision-prone goods; hard-gate the refund on serial match; kill EAN collisions in master data (unique EAN per sellable variant, enforced by a validation interceptor); grade condition on receipt and let grade drive both refund amount and restock eligibility; require serial match or supervisor approval for refunds above a margin threshold.

The exploit. A "Home Theatre Pack" sells online for €899 = soundbar (list €599) + subwoofer (€299) + rears (€199), a €198 bundle discount. Priya buys it, wants only the soundbar, and returns just the subwoofer and rears to a store. The associate scans them as individual products at individual list prices and refunds €498. Priya's net cost for the soundbar is €401 versus its €599 standalone price — she has pocketed the bundle discount that was conditional on keeping the set. The mirror exploit returns the full box missing a component and gets the full €899 if no completeness check exists.

The version with no malicious customer at all: the same return accepted at a franchise (separate legal entity). The franchise refunds the customer, then files an intercompany reimbursement claim at €1,097 (sum of parts) while the brand only ever collected €899. The €198 delta is pure intercompany leakage, settled weeks later in bulk where the discrepancy is invisible. Multiply across thousands of franchise returns: structural margin erosion no fraud team is watching.

The mechanism. The order/pricing service treats the bundle as one priced unit with a conditional discount; the store's return service treats what's in front of it as N independent products with independent prices and EANs. They disagree on whether the object is one thing or three, and the discount's precondition — integrity of the set — is not carried into the return path. In the intercompany case, the selling and accepting entities disagree on who owns the money and at what valuation the return is credited.

SAP Commerce verdict.

The bundle module (configurablebundleservices/bundleservices) models BundleTemplate, component products, and pricing via bundle rules — but crucially it represents a bundle in the order as multiple AbstractOrderEntry rows linked by a bundleNo, not one atomic line. So at the order-entry level the bundle is already decomposed, and ReturnEntry (per order entry + quantity) will happily create a return for a single component. Component-level return is the default, not a blocked edge case; there is no OOTB constraint that a bundle returns in full, and no OOTB repricing that claws back the bundle discount from retained components. Closing it is custom: a return-time rule keyed on bundleNo that either requires the whole bundle back or invokes a broken-bundle repricing (an AbstractBundleRule extension) removing the discount and repricing retained components to standalone before computing the RefundEntry. (If instead you model a kit as a single non-decomposed SKU, the platform has no knowledge the box contains three scannable items — the completeness check is entirely custom and partly physical.)

Intercompany financial settlement between brand and franchise lives in the ERP (S/4HANA intercompany billing), not in Commerce — there is no native concept of "accepting entity ≠ selling entity" for settlement. Franchise contracts (who eats a discrepancy, at what valuation) are legal/commercial. The best the platform can do is stamp the accepting legal entity and the exact paid bundle valuation on every return and emit a structured settlement record so reconciliation is automated and same-basis, instead of a manual list-price claim.

Detect it. Find bundles returned as a proper subset of their components: group OrderEntry by bundleNo, count entries per bundle, and compare against the ReturnEntry count per bundleNo — any bundle with only some components returned is a candidate. Then a refund-versus-paid check: sum RefundEntry.amount per bundle against the bundle's paid totalPrice, flagging where component refunds exceed the allocated bundle price. On the ERP side, watch the intercompany clearing account for a persistent one-directional imbalance from franchise return claims.

The test. Buy the €899 bundle, return two of three components: is the refund €498 (list, exposed) or the clawed-back bundle-allocated value (correct), and is the retained component repriced to standalone? Then the intercompany question: "For a bundle returned at a franchise store, show the automated reconciliation record — is the reimbursement the bundle-allocated price the customer actually paid, or a manual claim at component list price?"

Prevent it. Keep the bundleNo/bundleTemplate linkage authoritative and make a component return trigger evaluation of the whole bundle — full-bundle return or broken-bundle repricing, never component refunds at list from a discounted bundle. Add a completeness gate for kit-SKUs. Make settlement entity-aware: stamp the accepting entity and paid valuation, emit a same-basis intercompany record to the ERP, and reconcile per transaction rather than netted in bulk.

Status and time

Channel asynchrony double-refund

The exploit. Maya bought a €640 espresso machine (single line, qty 1). At 10:12 she starts an online return — SAP Commerce creates RMA-77341, auto-approved to WAIT. At 10:40, instead of the drop-off point, she walks into a store; the POS looks up the order via the OMS, sees the line still returnable, and refunds €640 to her card — without calling back into Commerce's ReturnRequest graph. She never cancels the online RMA. The nightly reconciliation batch imports the POS return but the record collides and is dropped as a "duplicate" warning nobody reads. Three days later a warehouse scans goods-in against RMA-77341, the return process advances to the refund action, and a second €640 is reversed. The same trick works as in-store refund plus bank chargeback.

The mechanism. The POS/OMS assumes "I took the goods and settled the money — done." The Commerce ReturnRequest assumes "this RMA is still WAIT; the line still has returnable quantity; when goods arrive I owe a refund." Neither treats "money owed for this order line" as a single, locked, cross-channel fact.

SAP Commerce verdict.

The platform models the return graph well: ReturnRequest with its ReturnStatus state machine (APPROVAL_PENDING, WAIT, RECEIVED, PAYMENT_REVERSED, COMPLETED, CANCELED, CANCELLING, plus payment/tax-reversal states like PAYMENT_REVERSAL_FAILED, among others), ReturnEntry pointing at the order entry, RefundEntry carrying the amount, and a task-engine return-process where the RECEIVED transition is the standard money-release seam.

Standard DefaultReturnService does not prevent two concurrent ReturnRequests against the same OrderEntry. It only checks remaining returnable quantity (getAllReturnableEntries / OrderReturnTool), and that check is not transactionally locked across two creations — two requests each reading "returnable = 1" can both be created before either commits. Worse, the returnable-quantity accounting only knows about returns created inside SAP Commerce; a POS/OMS refund that never wrote a ReturnEntry back is invisible to it. So even a perfect single-system guard wouldn't close this. You need a single real-time return-status authority at order-line granularity, a distributed idempotency key / lock on (orderCode, entryNumber, reason), and refunds gated on verified receipt rather than on any status a channel can set.

The store POS and its refund rails, the external OMS/ERP settlement ledger, the carrier/PUDO scan events, and the bank chargeback network. In many omnichannel landscapes SAP Commerce is a participant, not the ledger — an external OMS or S/4HANA owns settlement, and reconciliation, not a foreign key, is what ties the ledgers together.

Detect it. Find order lines with more than one return, and refund totals exceeding the line value:

SELECT {o.code}, {oe.entryNumber},
       SUM({rf.amount}) AS refunded, {oe.totalPrice} AS lineTotal
FROM   {RefundEntry AS rf
        JOIN ReturnEntry AS re ON {rf.pk}={re.pk}
        JOIN OrderEntry  AS oe ON {re.orderEntry}={oe.pk}
        JOIN Order       AS o  ON {oe.order}={o.pk}}
GROUP BY {o.code}, {oe.entryNumber}, {oe.totalPrice}
HAVING SUM({rf.amount}) > {oe.totalPrice}

Correlate return-process business processes in SUCCEEDED state with orders that also appear in the POS-return reconciliation feed, and audit the CronJob history of that import for swallowed "duplicate return" warnings — that is where the collision hides. On the payment side, look for two refunds to the same card against the same original auth reference within the reconciliation window.

The test. Create two ReturnRequests for the same OrderEntry from two entry points concurrently (native service + OCC API, overlapping transactions). Do both succeed? Advance both to RECEIVED and count the RefundEntry rows and total captured. Then inject a POS-return record for the same line via the reconciliation feed and see whether the system rejects, merges, or double-books. The audit question: "Show me the transaction and lock boundary that guarantees the sum of settled refunds for one order entry can never exceed its net value, across POS, OMS, and Commerce." If the answer is "the nightly reconciliation catches it," the gap is open.

Prevent it. Gate money on physical RECEIVED only, set by the warehouse against a matched SKU/serial — never on portal creation or a POS "accepted." Put a single return ledger (a dedicated service, or the designated OMS) in charge of per-order-line return state; every channel calls it synchronously to reserve returnable quantity before promising a refund. Attach distributed idempotency keys to every refund command so a replayed or second settlement is a no-op. Model return-and-refund as a saga with a transactional outbox so a crash can't double-emit. Keep reconciliation, but promote its "duplicate" warnings to hard, blocking alerts with an auto-hold on the second payout.

Scan payout triggers and phantom PUDO returns

The exploit. Two shapes. Cancel-after-ship: Dan orders a €1,200 laptop; at 18:00 the consignment is SHIPPED and with the carrier; at 18:03 he hits "Cancel order," the cancel flow is wired to auto-refund on confirmation, and €1,200 is credited. The parcel is delivered anyway. Dan keeps the laptop. Phantom PUDO return: Nadia returns a €300 jacket via a drop-off point; the carrier scan RETURN_ACCEPTED is mapped by an inbound integration to ReturnStatus = RECEIVED, the return process advances, €300 is refunded — and six days later the box reaches the warehouse containing a brick. No content check is wired to the refund because it already fired. At thousands of parcels a day, that is the systemic leak.

The mechanism. Two services disagree on what event means "money owed." The carrier/PUDO emits "the parcel entered my custody" — a logistics fact. The refund trigger interprets any RECEIVED-shaped signal as "we own verified, correct goods" — a settlement fact. For cancel-after-ship, the cancellation service assumes "cancel requested ⇒ shipment stoppable ⇒ safe to refund," while the carrier has already moved past the point of recall.

SAP Commerce verdict.

The platform models cancellation (OrderCancelService, CancelRequestRecordEntry, eligibility gated by consignment status) and the return RECEIVED transition as the standard money-release seam, with a manual goods-receipt step in the standard process.

Whether a cancel auto-triggers a refund is a configuration/extension decision — that is the cancel-after-ship hazard. And RECEIVED can be set by any integration: if a PUDO/carrier feed PATCHes it, the payout fires with no content verification, because there is no built-in serial/SKU-match gate between RECEIVED and the refund action, and the quality-inspection step that would block the refund is a modeling choice, not a default. The platform does not natively distinguish "carrier accepted" from "warehouse verified correct goods" — both flatten to RECEIVED. The fix is a two-phase status (CARRIER_ACCEPTED, informational, no money; RECEIVED_VERIFIED, warehouse content + serial match, releases money), a mandatory blocking content-verification node before the refund action, and a cancel-refund guard that re-checks consignment status atomically and converts a post-SHIPPED cancel into a return-on-arrival flow.

The carrier and PUDO scan systems and their event semantics, the physical warehouse inspection station, and the external OMS if it owns cancel/settlement. These feeds arrive async (SCPI/Kafka/impex) with no ordering or exactly-once guarantee.

Detect it. Inspect the return-process definition XML for whether a quality/verification node sits before the refund action, or whether RECEIVED → refund is a direct edge, and check ProcessTaskLog for skipped inspection steps. Grep the inbound integration (SCPI iFlow / Kafka consumer / impex) that writes ReturnStatus: if a carrier RETURN_ACCEPTED maps to RECEIVED, that is the smoking gun. For cancel-after-ship, find CancelRequestRecordEntry rows whose consignment is already SHIPPED, correlated with refunds timestamped after the ship event. Out of platform: parcels marked accepted at a PUDO with no matching warehouse goods-in within N days.

The test. Advance a consignment to SHIPPED, then call OrderCancelService.requestOrderCancel — does a refund fire immediately, with no re-verification of goods recall? Separately, fire a synthetic carrier RETURN_ACCEPTED event for an open RMA and assert whether ReturnStatus jumps to RECEIVED and a payment reversal is produced without any warehouse goods-in or quality-check record; then deliver a mismatched SKU and see whether anything claws back. The audit question: "Between the money-releasing state and the physical event, what verifies that the correct SKU/serial was received? Name the mandatory process node and show a return that was blocked because verification failed."

Prevent it. Gate money on verified physical receipt only — an explicit RECEIVED_VERIFIED state set by the warehouse after SKU/serial and condition match, with the refund edge originating there and never from a carrier scan. Make carrier scans update a customer-visible tracking status that carries zero settlement authority. Model cancel-after-ship as a saga: past SHIPPED, refund becomes a compensating step gated on goods return, with a timeout that escalates rather than auto-pays. Ingest carrier/PUDO events idempotently and enforce state-machine legality so a late or duplicate scan can't re-trigger a settled transition.

Claims and exchange

"Item missing" claim and partial return

The exploit. A three-line order — headphones (€220), SSD (€180), keyboard (€140), total €540 — ships in one parcel; all three arrive. Marcus opens a CRM claim: "the SSD was missing from the box." A care agent issues a goodwill re-ship (or a €180 credit) with no reconciliation against the carrier's confirmed delivery. Two days later Marcus walks into a store with all three items; the POS scans the order barcode and shows three returnable lines at ordered quantity, and he returns all three for €540. The SSD is paid out twice — once as the re-ship, once inside the €540 refund. Net leakage €180 per cycle, and nothing links the claim to the returnable quantity.

The mechanism. The claims actor assumes "line 2 never reached the customer" and issues compensation without decrementing what the customer still holds. The returns actor assumes "returnable quantity = ordered minus already-returned" and authorizes a refund on line 2 because, from the order's perspective, it was never returned. Neither consults delivered-net-of-claim truth. The claim and the return are siblings that never see each other.

SAP Commerce verdict.

The platform models ordered quantity (OrderEntry.quantity), the delivery signal (ConsignmentEntry.shippedQuantity, ConsignmentStatus.DELIVERED), and the return graph — but OOTB returnable quantity is computed as ordered minus already-returned (DefaultReturnService.getAllReturnableEntries / OrderReturnTool). It does not subtract quantities credited under a missing-item claim, and the stock accelerator does not even hard-require a DELIVERED consignment. Closing it is custom: override getAllReturnableEntries (or the surrounding facade) so returnable qty = delivered − already-returned − claimed/credited-missing; model the claim as a first-class item on the order (a MissingItemClaim referencing the order entry) that factors into the calculation; and treat a re-ship as consuming the entitlement (zero the original line's returnable qty, transfer returnability to the replacement consignment). Note DELIVERED normally reflects a carrier/warehouse update, not a per-item customer confirmation that the item was physically in the box.

The WISMO/missing-item claim usually originates in an external CRM/CCaaS (Salesforce Service Cloud, Zendesk, Genesys) and the goodwill credit may be issued there and never written back to Commerce — that is the core break: the claim lives in a system the returns engine doesn't read. The store POS terminal and carrier parcel-weight reconciliation are also external. (If you run SAP Order Management rather than the stock accelerator, DELIVERED handling and returnable-quantity behaviour are richer — verify your OMS return facade.)

Detect it. Find refunds on lines whose consignment never reached DELIVERED:

SELECT {o.code}, {oe.entryNumber}, {re.receivedQuantity},
       {ce.shippedQuantity}, {c.status}
FROM   {ReturnEntry      AS re
        JOIN OrderEntry      AS oe ON {re.orderEntry}={oe.pk}
        JOIN Order           AS o  ON {oe.order}={o.pk}
        JOIN ConsignmentEntry AS ce ON {ce.orderEntry}={oe.pk}
        JOIN Consignment     AS c  ON {ce.consignment}={c.pk}}
WHERE  {c.status} <> ?delivered

The decisive join can't be done inside one system: export goodwill/re-ship credits from the CRM keyed by order code and find orders that have both a missing-item credit and an in-store refund on the same entry number. That join is the smoking gun, and its absence from any single system is the whole vulnerability.

The test. Create a three-line single-parcel order, mark consignments DELIVERED, file a missing-item claim on line 2 in the CRM and issue a re-ship, then attempt an in-store return of all three lines. Does the returns/POS UI still offer line 2 as returnable at full quantity? If yes, the system trusts ordered quantity, not delivered-net-of-claim. Ask the architect: "Name the single service that computes returnable quantity, and show me every input it reads. Does any input come from the claims system?" If not, the gap is open by construction.

Prevent it. Make one service the source of returnable truth — returnable qty = delivered − already-returned − claimed/credited-missing — and make it the only path both web and POS returns call. Persist claims on the order graph and write CRM goodwill credits back synchronously (or block the credit until they are). Treat a re-ship as replacing the entitlement. Delivery-gate returns, requiring a supervisor override with reason code for lines with no DELIVERED consignment. Run a nightly reconciliation joining CRM credits to Commerce refunds by order + entry, alerting before settlement.

Exchange as a way to "cash out"

The exploit. Elena buys Jacket A online under a promo: list €2,000, 40% off, paid €1,200. The promo later expires; the store price of A is back to €2,000. She exchanges A in-store for Jacket B at store price €3,500. The correct top-up from what she paid is €3,500 − €1,200 = €2,300; but if the exchange computes the differential from the store price of A, it charges €3,500 − €2,000 = €1,500 — she underpays €800. The exchange mints a new receipt for B at €3,500: the 40%-off promo lineage is gone and the return window restarts. She exchanges B for Jacket C at €5,000, differential computed from B's receipt value (€5,000 − €3,500 = €1,500). Then C, inside its fresh window, is returned for €5,000.

Trace the cash: she pays €1,200 (A) + €1,500 (A→B) + €1,500 (B→C) = €4,200 in; she is refunded €5,000 on C. The retailer is out ~€800 and has all three jackets back — and that €800 is exactly the promo value laundered at the A→B hop (charging the €2,000 store basis instead of the €1,200 paid basis). The cash number is small, but the structural damage is not: the 40%-off promo has been honoured on merchandise it never applied to, and the return window has been reset so it never closes. A chain of these is an unbounded, promo-laundering, always-returnable machine.

The mechanism. Two actors disagree about the identity and cost basis of "the order" across the exchange. The original order (the promotion engine's world) carries PromotionResults, a paid price of €1,200, and a return-window clock from the original delivery. The exchange receipt (the POS/new-order world) is a brand-new order priced at current store value, with no promotion inherited, a fresh window, and a cost basis equal to this receipt's value. The customer's economic position in the merchandise is modeled as two unlinked orders, so order #2 launders the item clean of its discount history and its returned-once history.

SAP Commerce verdict.

/ Promotions (PromotionResult, PromotionOrderEntryConsumed) are computed against a specific AbstractOrder and bound to it — there is no OOTB concept of promotion lineage that follows an item into a different order. Paid price lives on the original order entry; a new order recalculates from current PriceRows and current promotions. Critically, SAP Commerce OOTB returns supports refund and thin replacement semantics; a true exchange that creates a replacement order for a different SKU with a computed differential is almost always a custom flow (or delivered by SAP OMS / a POS product). So the whole lineage problem is custom to close: give every exchange/replacement order a hard reference to the original order and entry; compute the differential from the original OrderEntry.totalPrice net of PromotionOrderEntryConsumed, not from current PriceRow; carry the promo-lineage policy forward explicitly (transfer proportionally or price the exchange at full undiscounted delta — pick one, forbid the silent-drop path); and inherit the original return-window start on the chain root so every exchanged item shares one clock. The store POS is where this actually breaks: if POS uses its own catalog and mints its own receipt number, it re-bases the price and resets the window entirely outside Commerce's view.

Detect it. If you added an originalOrder reference on exchange orders, find exchange-children that carry no promotion and started a fresh clock:

SELECT {o.code}, {o.date}, {o.originalOrder}, {o.totalPrice}
FROM   {Order AS o}
WHERE  {o.originalOrder} IS NOT NULL
  AND  NOT EXISTS ({{ SELECT 1 FROM {PromotionResult AS pr}
                      WHERE {pr.order}={o.pk} }})

Any exchange-child with a materially higher total than its parent and no inherited promo is a candidate. Also query PromotionOrderEntryConsumed for discounts "spent" on merchandise the customer no longer holds, and audit POS receipt chains with resetting dates and no back-reference to the original web order. If the chain link is missing entirely, that missing link is the finding.

The test. Buy under a 40%-off promo online; after the promo ends, exchange in-store for a pricier SKU, then exchange again. Then check three things: was each top-up computed from the originally-paid price or the current store price of the returned item; does the final order carry any trace of the original promotion; and what is the return-window start date on the final item — original purchase or last exchange? "Show me the exchange receipt's link back to the original web order, and which price field the differential engine subtracts." No back-link and a current-catalog field means both the re-basing and the window-reset leaks are open.

Prevent it. Treat an exchange as a first-class, linked transaction — never as "refund + fresh sale," because that decomposition is exactly what severs lineage. Resolve the first order in the chain for both the paid-price basis and the return-window start; never let either reset on a hop. Make one differential service — reading paid price net of consumed promotions — the only path both POS and web call, and cap total exchanges per lineage. Where POS is offline-capable, reconcile and claw back on sync.

What the ten leave out

These ten were chosen for how cleanly they show the "two services, one object, different assumption" pattern — not because they exhaust it. A few well-known siblings that belong on any real audit:

Each is the same shape, and each is worth a row in your own version of the table below.

Responsibility for the fix

The ten gaps do not distribute evenly across the three verdicts, and the distribution is itself the headline. SAP Commerce Cloud is strong on the order record — it freezes the paid price, allocates discounts, models the return graph and the promotion results. It is weak exactly where an omnichannel business needs it most: refunding the paid price rather than the base-price default, recomputing conditional promotions at return time, split-tender refunds, serialized unit identity, cross-channel return locking, delivered-net-of-claim quantity, and exchange lineage. And several of the most expensive gaps aren't its job at all.

# Gap Platform Custom dev Not the platform
1 Shelf-price refund Freezes paid price to the order Override base-price default; POS↔Commerce price resolution; RMA-first POS catalog, override button
2 Cart-discount reallocation Stores discount allocation & PromotionResult Re-run promo engine on retained cart "Locked once earned" policy
3 Loyalty / cashback clawback Can trigger reversal on return Proportional reversal integration Loyalty & cashback ledgers
4 Non-cash → cash Payment transactions collection Tender ledger; refund-to-origin Gift-card, BNPL, PSP
5 Unit substitution Type identity, variants, stock qty Serialize at fulfillment; serial gate Physical inspection, ERP serial
6 Bundles / entities Bundle module; paid valuation Bundle-integrity & claw-back on return Intercompany settlement, contracts
7 Double refund (async) Return status machine Cross-channel lock; single return ledger POS rails, chargeback network
8 Scan-triggered payout Cancel & return processes Verified-receipt gate; two-phase status Carrier/PUDO scan systems
9 "Missing item" + return Ordered qty, delivery status Delivered-net-of-claim returnable qty CRM/CCaaS claim ledger
10 Exchange laundering PromotionResult bound to order Linked exchange; lineage & window inherit POS exchange receipt/catalog

Two patterns worth stating plainly. First, the column is almost always the same move — designate a single authoritative service for the contested value (returnable quantity, refund amount, tender split, unit identity) and force every channel to call it. Second, the column is never solved by a platform feature — it is solved by an integration contract with idempotent, proportional, tender-aware, verified-receipt semantics, plus daily reconciliation across the ledgers that each hold a locally-correct piece of the truth.