Acumatica resolves a line price and a line discount as two separate lookups, in a defined order, using the customer, the inventory item, the quantity, and the effective date. Most "why did it charge that?" tickets come down to not knowing that order. Once you can trace which sales price record and which discount sequence won, the engine stops feeling like a black box.
Price and discount are two engines
The unit price on a sales order line comes from the Sales Prices table (screen AR202000), matched by price type, customer or customer price class, item, unit of measure, quantity break, and effective date. The discount is a separate resolution against Discount Codes and their sequences. A line can pick up a base price from one place and then have a line, group, or document discount stacked on top. Keeping these mentally separate is the first step: the price answers "what does this cost," the discount answers "what do we take off."
How the price is resolved
When a line is entered, the engine looks for the most specific applicable sales price and works outward. Customer-specific prices beat customer-price-class prices, which beat the item's base price. Within each level, quantity breaks and the effective/expiration dates filter further, and the promotional (override) prices take precedence over regular ones. The result is the unit price before any discount.
1. Customer + item, promotional price (effective today)
2. Customer + item, regular price (best quantity break)
3. Customer price class + item
4. Base price (item's default price list)
↓ first match with a valid effective date wins
The date the engine matches against is the order date, not today. Back-dating an order can pull an expired promotional price, and future-dating can miss a promotion that has not started. When a price looks wrong, check the document date before you touch the price list.
Line, group, and document discounts
Acumatica applies discounts at three levels, and they can compound. A line discount reduces one line. A group discount applies across a set of lines that together meet a threshold (buy 100 across these items, get 5% on all of them). A document discount applies to the order total. Whether they stack or one excludes the others is controlled per discount code and by the discount sequence priority — this is where "we gave them the volume discount and the promo" bugs live.
Line discount → one SOLine
Group discount → a qualifying set of lines
Document discount→ the whole SOOrder total
Each discount code has a Sequence; sequences are tried by
priority. A code marked "skip remaining" stops the chain.
Quantity breaks and volume tiers
Both sales prices and discounts support break-by-quantity or break-by-amount tiers. A sales price record can hold several rows — 1–9 at one price, 10–49 at another — and the engine picks the tier the ordered quantity falls into. Volume discounts work the same way on the discount side. The gotcha is unit of measure: a quantity break defined in EA does not automatically apply to a line entered in CS (case) unless the UoM conversion and the break's UoM line up. Mismatched UoM is a common reason a tier "does not trigger."
Changing the customer, adding lines that cross a group threshold, or editing the document date does not always re-run discount resolution on lines already entered. The Recalculate Prices action on the order (with the option to overwrite manual prices, or not) is what forces a clean re-resolution. If a rep changed a price by hand, recalculation will respect or clobber it depending on that flag.
Reading the resolved values in code
When you need to react to the engine — say, block a manual price below the resolved one — the values you care about sit on SOLine: CuryUnitPrice (the resolved unit price), DiscPct and CuryDiscAmt (the line discount), and ManualPrice/ManualDisc flags telling you whether a human overrode the engine. Hook RowUpdated or a field event rather than trying to re-implement resolution yourself.
public class SOOrderEntryExt : PXGraphExtension<SOOrderEntry>
{
protected void _(Events.RowUpdated<SOLine> e)
{
var row = e.Row;
if (row == null || row.ManualPrice != true) return;
// Compare the rep's manual price against what the engine resolved.
decimal? floor = PXPriceCostAttribute.GetCustomerPrice(
Base, e.Cache, row, row.OrderDate);
if (floor.HasValue && row.CuryUnitPrice < floor)
e.Cache.RaiseExceptionHandling<SOLine.curyUnitPrice>(
row, row.CuryUnitPrice,
new PXSetPropertyException(
"Price is below the resolved customer price.",
PXErrorLevel.Warning));
}
}
Prefer the framework's own resolution helpers over querying the price tables directly — they honour effective dates, UoM conversion, and precedence exactly the way the UI does, so your validation matches what the rep sees.
Which record wins: a quick map
| Situation | What wins |
|---|---|
| Customer price and price-class price both match | Customer-specific |
| Promotional and regular price both effective | Promotional |
| Two quantity breaks overlap | The tier the quantity falls into |
| Manual price entered, then Recalculate run | Depends on the "override manual" flag |
| Multiple discount codes qualify | Highest-priority sequence; "skip remaining" stops the chain |
Wrapping up
The pricing and discount engine is deterministic once you accept it as two ordered lookups: most-specific sales price first, then a discount chain resolved by sequence priority. When a number looks wrong, check the document date, the UoM, and whether a manual override or a stale line is in play before you blame the price list. Use the framework's resolution helpers in code so your rules agree with the screen, and reach for Recalculate Prices to get a clean answer.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.