Processes

Order level pricing

Add extra line items to an order based on the entire cart - minimum fees, shipping, volume discounts, and post-processing minimums.

Order level pricing lets you add extra line items to an order based on what's in the entire cart.

Use it whenever prices of different parts relate to each other: minimum order fees, shipping costs, volume discounts, or minimum post-processing charges.

⚠️ Use the AI prompt in the code interface on the Phasio platform to get help with your custom requirements.


What you get

Your code runs once per order. Phasio gives you these variables:

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 = additional cost. Negative = discount.
createBands({...})Creates a lookup function for tiered pricing.
round(value, precision)Rounds to N decimal places.

How parts works

Each entry in parts looks like this:

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

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


How createBands works

createBands maps an input value to tiers. It returns the value of the highest threshold the input exceeds.

const getDiscount = createBands({
  50:  0.02,   // input >= 50  → 0.02
  100: 0.05,   // input >= 100 → 0.05
  200: 0.10,   // input >= 200 → 0.10
})

getDiscount(30)   // → 0      (below all thresholds)
getDiscount(75)   // → 0.02
getDiscount(150)  // → 0.05
getDiscount(500)  // → 0.10

You can set a custom default (instead of 0) as the second argument:

const getFee = createBands({ 100: 5, 500: 3 }, 10)
getFee(50)  // → 10  (below all thresholds, returns default)

Example 1 - Minimum Order Value

Problem: Every order must be at least €100.

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

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


Example 2 - Minimum Price per Material

Problem: Each material has a setup cost. Small orders of one material should still cover that cost.

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

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

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

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

Example 3 - Minimum Post-Processing Cost

Problem: A dyeing process requires a cartridge. If only a small amount of one color is ordered, there's still a minimum cartridge cost.

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

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

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

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

Grouping by color

If the minimum depends on the dye color (e.g. E-Coloring grouped per color), use specification.color as part of the grouping key:

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

// Group by post-process + color
const ppTotals: Record<string, { label: string; total: number; min: number }> = {}

parts.forEach(({ specification, price }) => {
  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
  })
})

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)
    })
  }
})

This way, 5 black Dyeing parts are grouped together, but 1 blue Dyeing part gets its own minimum check.


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 }) => {
  const mat = specification.material.name
  totals[mat] = (totals[mat] || 0) + price
})

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)
    })
  }
})

☝️ price is negative because it's a discount.


Example 5 - Shipping Cost (Bin Packing)

Problem: Estimate shipping by packing all parts into boxes.

This uses a "first fit decreasing" algorithm to pack parts into the fewest boxes.

Step 1 - Define box sizes

const PACKING_OFFSET_MM = 25

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 side for padding.

Step 2 - Helpers

function fitsInChamber(
  w: number, h: number, l: number,
  cX: number, cY: number, cZ: number
): boolean {
  const o: [number,number,number][] = [
    [w,h,l],[w,l,h],[h,w,l],[h,l,w],[l,w,h],[l,h,w]
  ]
  return o.some(([a,b,c]) => a<=cX && b<=cY && c<=cZ)
}

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, boxCount: 0, boxes: [] as {name:string,count:number}[] }

  const sorted = [...items].sort(
    (a, b) => b.width*b.height*b.length - a.width*a.height*a.length
  )

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

  for (const item of sorted) {
    let placed = false
    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
    }
    if (!placed) {
      let chosen = BOXES[BOXES.length - 1]
      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 summary = 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, boxCount: bins.length, boxes: summary }
}

Step 4 - Collect items and add shipping

const allItems: ShippingItem[] = []

parts.forEach(({ specification, requisition }) => {
  const { width, height, length, volume } = specification
  const density = 1.1  // g/cm³, adjust per material
  const weightG = (volume / 1000) * density
  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}× ${b.name}`).join(', ')
  addLineItem({ name: `Shipping (${label})`, price: shipping.cost })
}

Combining recipes

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

  1. Volume discounts
  2. Minimum material fees
  3. Minimum post-processing fees
  4. Shipping cost
  5. Minimum order value (always last)

Put minimum order value last so it accounts for all other adjustments.


Quick reference

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

Tips

  • Negative price = discount. Always use price: -amount for discounts.
  • createBands is your best friend for any tiered pricing. Define thresholds once, reuse everywhere.
  • round(value, 2) avoids floating-point rounding issues.
  • Group by whatever matters: material, color, post-process, or any combination using a string key like `${material}_${color}`.
  • Test edge cases: single part, empty cart, oversized part, mixed materials.
  • Code runs in a sandbox - no network calls, no timers, just pure math.

Last updated on