Java Discount Allocation: Solving the 1-Cent Rounding Error in Partial Refunds

The article presents a Java solution for allocating order-level discounts to individual items using integer arithmetic and deterministic remainder distribution, ensuring accurate partial refunds by persisting per-unit prices, allocated discounts, and payable amounts at checkout, with database schema and concurrency considerations.

LuTiao Programming
LuTiao Programming
LuTiao Programming
Java Discount Allocation: Solving the 1-Cent Rounding Error in Partial Refunds

The article opens with a concrete e-commerce scenario: an order contains one item priced at 19.99 CNY and two items at 9.90 CNY each, with a 10 CNY order-level discount. The total paid is 29.79 CNY. When a user later returns only one 9.90 CNY item, the system must decide how much to refund. If the discount is stored only at the order header and re-apportioned at refund time using proportional rounding, the sum of individual refunds can drift by one cent — either exceeding or falling short of the actual amount paid. Price changes or promotion adjustments after the fact would also produce different answers.

Why Naive Rounding Fails

The author illustrates the core problem with a minimal example: two items each priced at 1 cent, sharing a 1 cent discount. Each item's theoretical share is 0.5 cents. Rounding each independently yields either 2 cents total (both round up) or 0 cents (both round down). Because partial refunds need to know exactly which item "owns" each cent, the business cannot simply true-up against the order total after the fact.

Allocation Algorithm

The solution uses integer cents throughout. For each refundable unit, the algorithm computes: base = floor(discount * unitPrice / gross) using BigInteger to avoid long overflow.

The remainder (discount * unitPrice) % gross is kept as a BigInteger.

After assigning the integer base to every unit, the remaining undistributed cents ( discount - sum(base)) are given one by one to the units with the largest remainders. Ties are broken deterministically by SKU, then by unit sequence number ( unitNo). This guarantees the same snapshot always produces the same allocation, never exceeds the total discount, and leaves no unassigned remainder. Math.addExact and Math.multiplyExact are used for summation and multiplication so that any overflow fails fast instead of silently producing negative values.

Code Walkthrough

The DiscountAllocator class (JDK 17) defines: Item(sku, quantity, unitPriceCent) — input line item.

UnitCharge(sku, unitNo, grossCent, discountCent, payableCent)

— output per-unit snapshot.

Inner class Share holds intermediate fields during allocation.

The allocate(List<Item>, long discountCent) method:

Validates input (non-null, positive quantities/prices, unique SKUs, unit count ≤ 10,000, discount ≤ gross).

Computes total gross cents and total unit count using exact arithmetic.

For each unit of each item, calculates base and remainder via BigInteger.divideAndRemainder, accumulates distributed, and stores a Share.

Computes remaining = discountCent - distributed (exact subtraction).

Sorts shares by remainder descending, then SKU, then unitNo.

Increments discount of the first remaining shares.

Maps each Share to a UnitCharge with payableCent = grossCent - discountCent (exact subtraction).

The main method demonstrates the example from the article: item A (sku "A", qty 1, 1999 cents) and item B (sku "B", qty 2, 990 cents) with 1000 cents discount. Output shows A receives 502 cents discount (payable 1497), each B receives 249 cents (payable 741 each). Total discount = 1000, total payable = 2979, matching 3979 - 1000.

Persistence Schema

A minimal table order_unit_amount stores the snapshot at checkout:

CREATE TABLE order_unit_amount (
  order_id BIGINT NOT NULL,
  sku VARCHAR(100) NOT NULL,
  unit_no INT NOT NULL,
  gross_cent BIGINT NOT NULL,
  discount_cent BIGINT NOT NULL,
  payable_cent BIGINT NOT NULL,
  refund_status VARCHAR(16) NOT NULL DEFAULT 'NONE',
  PRIMARY KEY (order_id, sku, unit_no),
  CHECK (gross_cent = discount_cent + payable_cent)
) ENGINE=InnoDB;

Refund Processing

When a user returns one unit of B, the service reads that unit's persisted payable_cent (741) — the exact amount paid for that unit at checkout. Subsequent returns read the next unit's record. The three refunds sum to the original 2979 cents regardless of later price changes or promotion cancellations.

Concurrency safety: the refund request uses a conditional update to claim the unit:

UPDATE ... SET refund_status='PROCESSING' WHERE ... AND refund_status='NONE'

and checks affected rows. An idempotent refund request record (keyed by refund request ID) is written. The actual payment-gateway call happens after transaction commit, using a stable idempotency key. On timeout, the worker queries the gateway result before retrying — never blindly re-issues with a new key.

The author notes that shipping fees, platform coupons, merchant coupons, cross-store discounts, and taxes have different liable parties and must each keep their own allocation details and refund rules; they cannot be lumped into a single discountCent.

Boundary: Promotion Rules First

The algorithm only distributes an already-determined, capped discount amount. Business rules — e.g., "spend 100 save 20" combined with item coupons — must first decide which items participate, the application order of each promotion, and who bears the cost. The rounding logic must not hide rule decisions.

Verification Strategy

Beyond the hand-coded example, the author recommends automated property-based tests that generate random prices, quantities, and valid discount amounts. For each generated order, three invariants are checked: 1. Sum of all unit discounts equals the order discount. 2. For every unit, discount + payable = gross price. 3. Sum of all payable amounts equals gross total minus discount. Edge cases covered: zero discount, discount equal to gross total, multiple units with identical remainders, and arithmetic overflow. The author ran 1,000 seeded random orders during writing; production systems should include both edge cases and random regression tests.

The concluding point: the cent that seems like a trivial rounding difference at checkout becomes a critical, auditable amount during partial refunds, after-sales reconciliation, and financial settlement. Deciding its ownership at order creation eliminates downstream ambiguity.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

e-commerceJavaorder processingconcurrency controldiscount allocationBigIntegerpartial refundrounding error
LuTiao Programming
Written by

LuTiao Programming

LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.