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 1 | Level 2 | Level 3 | |
|---|---|---|---|
| Runs | Once per part line | Once per part × post-process | Once per order |
| Output | done(unitPrice) | done(unitPrice) | addLineItem({ name, price }) |
| Sees other parts? | No | No | Yes — full parts array |
| Sees customer information | Yes | Yes | Yes |
Can use variable()? | Yes | Yes | No |
| Can use gates? | Yes | Yes | No |
| Relevant for lead time | Yes | Yes | No |
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
| Variable | What it is |
|---|---|
parts | All parts in the order. Each has price, specification, requisition, revision. |
subtotal | Sum of all part prices before order-level adjustments. |
addLineItem({ name, price }) | Adds a line to the order. Positive = charge. Negative = discount. |
customer | The 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:
- Volume discounts
- Minimum per material
- Minimum per post-process
- Shipping cost
- Minimum order value (always last — so it accounts for all other adjustments)
Quick reference
| I want to… | Pattern |
|---|---|
| Charge a minimum order total | Compare subtotal, addLineItem the difference |
| Minimum per material | Sum by material.name × quantity, top up if below threshold |
| Minimum per post-process | Sum by postProcessing[].name (+ colour), top up |
| Volume discount | Sum by material × quantity, use createBands, add negative price |
| Shipping estimate | Collect dimensions × quantity, run bin-packing, add box costs |
Tips
- Negative price = discount. Always use
price: -amountfor discounts. createBandsis your best friend for tiered pricing - define thresholds once, reuse everywhere.round(value, 2)on every money value passed toaddLineItem- never skip it.- Multiply by
requisition.quantitywhen aggregating.part.priceis 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
Creating a Custom Post-Process
A guide to building a custom post-process equation from one of three templates - flat fee, percentage of process price, or geometry-based - plus shared rules for all post-process equations.
Review Required Gate
How the review-required gate holds a part back from the customer until you manually approve its price, with patterns for triggering it.