PricingDeep Dive

Level 3 - Order Level Pricing

How to price at the order level - running once per order to apply minimum fees, volume discounts, post-processing minimums, and shipping costs across the whole cart.

Order level pricing runs once per order and sees every part in the cart. Use it whenever prices of different parts relate to each other: minimum fees, volume discounts, shipping costs, or post-processing minimums.

Unlike Level 1 and Level 2, there is no done() call. You add line items using addLineItem() and Phasio applies them to the order total.

What makes Level 3 different

Level 1Level 2Level 3
RunsOnce per part lineOnce per part × post-processOnce per order
Outputdone(unitPrice)done(unitPrice)addLineItem({ name, price })
Sees other parts?NoNoYes — full parts array
Sees customer informationYesYesYes
Can use variable()?YesYesNo
Can use gates?YesYesNo
Relevant for lead timeYesYesNo

The key shift: at Level 3 you are not pricing a single part. You are adjusting the order total based on the composition of the whole cart/order.

What you have available

VariableWhat it is
partsAll parts in the order. Each has price, specification, requisition, revision.
subtotalSum of all part prices before order-level adjustments.
addLineItem({ name, price })Adds a line to the order. Positive = charge. Negative = discount.
customerThe cart/order customer with information on isApproved, organisationId, organisationName, taxExcempt.
createBands({...}, base?)Creates a tiered lookup function.
round(value, precision)Rounds to N decimal places. Always use round(x, 2) for money.

The parts object

Each entry in parts looks like this:

{
  price: 42.50,                    // Level 1 unit price for this line item
  requisition: { quantity: 10 },   // how many units ordered
  specification: {
    volume: 12000,                 // mm³
    area: 8500,                    // mm²
    width: 50, height: 30, length: 80,
    material: { name: 'PA12' },
    postProcessing: [{ name: 'Black Dye', price: 3.50 }],
    color: 'black'
  }
}

You loop through parts, group or sum what you need, then call addLineItem().

Warning

price is the unit price for that line item, not the line total. Multiply by requisition.quantity when aggregating across units.

createBands is covered in full in the Reference page. The examples below use it directly.

Example 1 — Minimum Order Value

Problem: Every order must be worth at least €100.

if (subtotal < 100) {
  addLineItem({
    name: 'Minimum Order Fee (€100)',
    price: round(100 - subtotal, 2)
  })
}

Customer orders €40 of parts → a €60 fee is added automatically.

Example 2 — Minimum Per Material

Problem: Each material has a setup cost. A small order of one material should still cover that cost, even if other materials in the cart are fine.

const minPrices: Record<string, number> = {
  'PA12': 48,
  'PA11': 69,
  'TPU':  69,
}

const totals: Record<string, number> = {}

parts.forEach(({ specification, price, requisition }) => {
  const mat = specification.material.name
  if (minPrices[mat]) {
    totals[mat] = (totals[mat] || 0) + price * requisition.quantity
  }
})

Object.keys(totals).forEach(mat => {
  if (totals[mat] < minPrices[mat]) {
    addLineItem({
      name: `Min. order fee — ${mat}`,
      price: round(minPrices[mat] - totals[mat], 2)
    })
  }
})

Order has 1× PA11 part at €20 → invoice shows Min. order fee — PA11: €49.

Example 3 — Minimum Post-Processing Cost

Problem: A dyeing process requires a cartridge. If only a small amount of one colour is ordered, the minimum cartridge cost still applies.

const ppMinCosts: Record<string, number> = {
  'Black Dye': 20,
  'Blue Dye':  50,   // rare colour = higher minimum
}

const ppTotals: Record<string, number> = {}

parts.forEach(({ specification, price, requisition }) => {
  if (!specification.postProcessing) return
  specification.postProcessing.forEach(pp => {
    if (ppMinCosts[pp.name]) {
      ppTotals[pp.name] = (ppTotals[pp.name] || 0) + (price + pp.price) * requisition.quantity
    }
  })
})

Object.keys(ppTotals).forEach(ppName => {
  if (ppTotals[ppName] < ppMinCosts[ppName]) {
    addLineItem({
      name: `Min. charge — ${ppName}`,
      price: round(ppMinCosts[ppName] - ppTotals[ppName], 2)
    })
  }
})

Grouping by colour

If the minimum applies per colour (e.g. dyeing grouped per colour bath), use specification.color as part of the grouping key:

const ppMinCosts: Record<string, number> = {
  'Dyeing':       50,
  'Vapor Smooth': 80,
}

const ppTotals: Record<string, { label: string; total: number; min: number }> = {}

parts.forEach(({ specification, price, requisition }) => {
  if (!specification.postProcessing) return
  specification.postProcessing.forEach(pp => {
    if (!ppMinCosts[pp.name]) return
    const groupKey = `${pp.name}_${specification.color || 'default'}`
    if (!ppTotals[groupKey]) {
      ppTotals[groupKey] = {
        label: `${pp.name}${specification.color ? ` (${specification.color})` : ''}`,
        total: 0,
        min: ppMinCosts[pp.name],
      }
    }
    ppTotals[groupKey].total += (price + pp.price) * requisition.quantity
  })
})

Object.keys(ppTotals).forEach(key => {
  const { label, total, min } = ppTotals[key]
  if (total > 0 && total < min) {
    addLineItem({
      name: `Min. charge — ${label}`,
      price: round(min - total, 2)
    })
  }
})

5 black Dyeing parts pool together. 1 blue Dyeing part is checked separately and gets its own top-up if needed.

Example 4 — Volume Discount Per Material

Problem: Reward customers who order a lot of one material.

const getDiscount = createBands({
   500: 0.02,
  1000: 0.05,
  2000: 0.08,
  5000: 0.10,
})

const totals: Record<string, number> = {}

parts.forEach(({ specification, price, requisition }) => {
  const mat = specification.material.name
  totals[mat] = (totals[mat] || 0) + price * requisition.quantity
})

Object.keys(totals).forEach(mat => {
  const pct = getDiscount(totals[mat])
  if (pct > 0) {
    addLineItem({
      name: `Volume discount — ${mat} (${(pct * 100).toFixed(1)}%)`,
      price: -round(pct * totals[mat], 2)  // negative = discount
    })
  }
})

€1,400 of PA12 in the order → invoice shows Volume discount — PA12 (5%): −€70.

Example 5 — Shipping Cost (Bin Packing)

Problem: Estimate shipping by working out how many boxes are needed and charging per box.

This uses a first-fit decreasing algorithm: sort parts largest-first, then fit each into the smallest open box that can hold it.

Step 1 — Define box sizes

const PACKING_OFFSET_MM = 25  // padding deducted from each interior side

const BOXES = [
  { name: 'S',  extX: 254, extY: 203, extZ: 152, weightKg: 5,  cost: 11 },
  { name: 'M',  extX: 305, extY: 254, extZ: 203, weightKg: 10, cost: 15 },
  { name: 'L',  extX: 406, extY: 305, extZ: 254, weightKg: 18, cost: 22 },
  { name: 'XL', extX: 508, extY: 406, extZ: 305, weightKg: 27, cost: 31 },
]

All dimensions in mm (external). PACKING_OFFSET_MM is subtracted from each interior side for foam and void space.

Step 2 — Helpers

// Returns true if a part fits in the chamber in any of the 6 possible orientations
function fitsInChamber(
  w: number, h: number, l: number,
  cX: number, cY: number, cZ: number
): boolean {
  return ([[w,h,l],[w,l,h],[h,w,l],[h,l,w],[l,w,h],[l,h,w]] as [number,number,number][])
    .some(([a,b,c]) => a <= cX && b <= cY && c <= cZ)
}

// Returns the usable interior dimensions of a box
function chamberOf(box: typeof BOXES[number]) {
  return {
    x: box.extX - PACKING_OFFSET_MM,
    y: box.extY - PACKING_OFFSET_MM,
    z: box.extZ - PACKING_OFFSET_MM,
  }
}

Step 3 — Pack items

interface ShippingItem { width: number; height: number; length: number; weight: number }

function packItems(items: ShippingItem[]) {
  if (items.length === 0) return { cost: 0, boxes: [] as { name: string; count: number }[] }

  // Sort largest-volume parts first (First Fit Decreasing)
  const sorted = [...items].sort(
    (a, b) => b.width * b.height * b.length - a.width * a.height * a.length
  )

  const bins: { box: typeof BOXES[number]; usedWeightG: number }[] = []

  for (const item of sorted) {
    let placed = false

    // Try to fit into an existing open box
    for (const bin of bins) {
      const c = chamberOf(bin.box)
      if (bin.usedWeightG + item.weight > bin.box.weightKg * 1000) continue
      if (!fitsInChamber(item.width, item.height, item.length, c.x, c.y, c.z)) continue
      bin.usedWeightG += item.weight
      placed = true
      break
    }

    // Open a new box — pick the smallest box the part fits in
    if (!placed) {
      let chosen = BOXES[BOXES.length - 1]  // fallback to largest
      for (let i = 0; i < BOXES.length; i++) {
        const c = chamberOf(BOXES[i])
        if (item.weight <= BOXES[i].weightKg * 1000 &&
            fitsInChamber(item.width, item.height, item.length, c.x, c.y, c.z)) {
          chosen = BOXES[i]
          break
        }
      }
      bins.push({ box: chosen, usedWeightG: item.weight })
    }
  }

  const cost = bins.reduce((s, b) => s + b.box.cost, 0)
  const boxes = bins.reduce((acc, b) => {
    const e = acc.find(x => x.name === b.box.name)
    if (e) e.count++; else acc.push({ name: b.box.name, count: 1 })
    return acc
  }, [] as { name: string; count: number }[])

  return { cost, boxes }
}

Step 4 — Collect all units and add shipping

const allItems: ShippingItem[] = []

parts.forEach(({ specification, requisition }) => {
  const { width, height, length, volume } = specification
  const density = 1.1  // g/cm3, adjust per material
  const weightG = (volume / 1000) * density
  // One item per physical unit, not per line
  for (let i = 0; i < requisition.quantity; i++) {
    allItems.push({ width, height, length, weight: weightG })
  }
})

const shipping = packItems(allItems)

if (shipping.cost > 0) {
  const label = shipping.boxes.map(b => `${b.count}x ${b.name}`).join(', ')
  addLineItem({ name: `Shipping (${label})`, price: shipping.cost })
}

Order with 12 parts → packed into 1x M + 1x S → invoice shows Shipping (1x M, 1x S): €26.

Combining recipes

You can stack multiple patterns in one equation. Recommended order:

  1. Volume discounts
  2. Minimum per material
  3. Minimum per post-process
  4. Shipping cost
  5. Minimum order value (always last — so it accounts for all other adjustments)

Quick reference

I want to…Pattern
Charge a minimum order totalCompare subtotal, addLineItem the difference
Minimum per materialSum by material.name × quantity, top up if below threshold
Minimum per post-processSum by postProcessing[].name (+ colour), top up
Volume discountSum by material × quantity, use createBands, add negative price
Shipping estimateCollect dimensions × quantity, run bin-packing, add box costs

Tips

  • Negative price = discount. Always use price: -amount for discounts.
  • createBands is your best friend for tiered pricing - define thresholds once, reuse everywhere.
  • round(value, 2) on every money value passed to addLineItem - never skip it.
  • Multiply by requisition.quantity when aggregating. part.price is the unit price, not the line total.
  • Group by whatever matters: material, colour, post-process, or any composite key like ${material}_${color}.
  • Test edge cases: single part, empty cart, oversized part, mixed materials, zero-price parts.

Last updated on

On this page