Pricing

Cookbook - Pricing Snippets

Copy-paste pricing snippets and complete starter equations for common manufacturing patterns. Find the pattern, copy the code, adjust the numbers.

Copy-paste snippets for common pricing patterns. Each snippet is self-contained and production-ready.

Find the pattern that matches your problem → copy the code → adjust the numbers to your costs. That's it.

Using AI?

Copy a snippet and describe what you want to change in plain English. The comments in each snippet give the AI the context it needs to adapt the code correctly.

Starter Equations

Complete, minimal, working equations - one per process. Copy the one that matches your setup and paste it into Phasio. Then use the AI prompt on the Start Here page to adapt it to your specific costs.

Each starter equation requires a few material variables to be created on your material in Phasio before saving. These are listed under each equation.

What makes a Starter Equation different from a snippet?

A snippet solves one specific problem (e.g. "add a minimum order fee"). A Starter Equation is a complete, runnable equation you can copy and go live with immediately.

FDM - Starter Equation

Approach: Material cost from shell + infill + support volumes (weight-based), plus machine time calculated from print speed and layer height. Includes machine selection, quantity discount via createBands, and a size gate.

// FDM pricing: shell + infill + support material cost, plus machine time.

const { material, width, height, length, volume, area, convexHullVolume, infill, precision } = specification
const { quantity } = requisition

// Machine selection: pick the right printer bed by largest part dimension
const CARBON_X1_BED = { length: 248, width: 248, height: 248, availablePrinters: 10 }
const H2D_BED       = { length: 300, width: 315, height: 315, availablePrinters: 1 }

function selectPrinter() {
  return Math.max(length, width, height) <= 250 ? CARBON_X1_BED : H2D_BED
}

// Constants
const NOZZLE_DIAMETER_MM  = 0.4
const NUMBER_OF_WALLS     = 3
const SUPPORT_INFILL      = 0.2   // support structures printed at 20% infill
const PRINT_TIME_OVERHEAD = 1.3   // +30% for travel moves and retracts

// Step 1: Volumes
const shellVolume  = Math.min(volume, area * NOZZLE_DIAMETER_MM * NUMBER_OF_WALLS)
const partInfill   = infill?.value ?? 0.20
const infillVolume = (volume - shellVolume) * partInfill

// Support estimated from convex hull overhang; overridable per order in UI
const defaultSupportVolume = Math.round((convexHullVolume - volume) * 0.1)
const supportVolume = variable('supportVolume', defaultSupportVolume)

// Step 2: Material cost
// Material variables to create before saving:
//   materialDensity   g/cm3  e.g. 1.24 for PLA
//   materialCostPerKg        e.g. 25.00
const materialDensity   = material.variables['materialDensity']
const materialCostPerKg = material.variables['materialCostPerKg']

// mm3 to cm3 to g to kg, then multiply by cost per kg
const mainWeight    = ((shellVolume + infillVolume) / 1000) * materialDensity / 1000
const supportWeight = ((supportVolume * SUPPORT_INFILL) / 1000) * materialDensity / 1000
const materialCost  = (mainWeight + supportWeight) * materialCostPerKg

// Step 3: Print time and machine cost
// Material variables to create before saving:
//   baseSpeedNozzle    mm/s  e.g. 200
//   wallSpeedFactor    0-1   e.g. 0.5  (walls print slower than infill)
//   infillSpeedFactor  0-1   e.g. 1.0
//   supportSpeedFactor 0-1   e.g. 0.8
//   printerCostPerHour       e.g. 4.50
const baseSpeed          = material.variables['baseSpeedNozzle']
const wallSpeedFactor    = material.variables['wallSpeedFactor']
const infillSpeedFactor  = material.variables['infillSpeedFactor']
const supportSpeedFactor = material.variables['supportSpeedFactor']
const printerCostPerHour = material.variables['printerCostPerHour']

const layerHeight = precision?.value ?? 0.20 // uses precision setting if configured

// Time per region in seconds (volume / cross-section printed per second)
const wallTime    = shellVolume  / (NOZZLE_DIAMETER_MM * layerHeight * baseSpeed * wallSpeedFactor)
const infillTime  = infillVolume / (NOZZLE_DIAMETER_MM * layerHeight * baseSpeed * infillSpeedFactor)
const supportTime = (supportVolume * SUPPORT_INFILL) / (NOZZLE_DIAMETER_MM * layerHeight * baseSpeed * supportSpeedFactor)

// Shell penalty: wall-heavy parts have proportionally more slow perimeters
const shellPenalty = 1 + 0.5 * Math.pow(shellVolume / (shellVolume + infillVolume), 2)

// Total print time in hours - exposed in UI so it can be overridden if needed
const printTimeHours = variable('printTime', round(
  ((wallTime + infillTime + supportTime) * PRINT_TIME_OVERHEAD * shellPenalty) / 3600, 2
))

const machineCost = printTimeHours * printerCostPerHour

// Step 4: Quantity discount via createBands
// base = 1.0 means no discount below the first threshold (under 10 units)
const getDiscount = createBands({
  10:  0.95,  // 10-49 units: 5% off
  50:  0.90,  // 50-99 units: 10% off
  100: 0.85,  // 100-249 units: 15% off
  250: 0.80,  // 250-499 units: 20% off
  500: 0.75,  // 500+ units: 25% off
}, 1.0)

const quantityDiscount = getDiscount(quantity)

// Step 5: Final price
const hardwareCost    = variable('Hardware Cost', 0) // for inserts, fixturing, etc.
const calculatedPrice = round((materialCost + machineCost + hardwareCost) * quantityDiscount, 2)
const finalUnitPrice  = variable('unitPrice', calculatedPrice)

// Gate: flag for review if part does not fit the selected printer
const printer = selectPrinter()
const reviewRequired = width > printer.width || height > printer.height || length > printer.length

done(finalUnitPrice, printTimeHours, reviewRequired)

Material variables to create before saving (Phasio → Materials → [material] → Variables):

  • materialDensity - g/cm³, e.g. 1.24 for PLA, 1.01 for TPU
  • materialCostPerKg - e.g. 25.00
  • baseSpeedNozzle - mm/s, e.g. 200
  • wallSpeedFactor - e.g. 0.5
  • infillSpeedFactor - e.g. 1.0
  • supportSpeedFactor - e.g. 0.8
  • printerCostPerHour - e.g. 4.50

SLA - Starter Equation

Approach: Resin cost from total volume (part + support + waste), plus machine time calculated layer by layer. Parts are assumed oriented at 45° to minimise support. Includes quantity discount via createBands.

// SLA pricing: material cost from total resin volume + layer-by-layer machine time.

const { material, width, height, length, volume, area, convexHullVolume } = specification
const { quantity } = requisition

// Material variables to create before saving:
//   resinCostPerLiter   e.g. 45.00
//   machineCostPerH     e.g. 8.00
const resinCostPerLiter = material.variables['resinCostPerLiter']
const machineCostPerH   = material.variables['machineCostPerH']

// Volumes
const partVolumeMm3    = volume
const supportVolumeMm3 = (convexHullVolume - volume) * 0.10  // ~10% of hull gap
const wasteVolumeMm3   = area * 0.05                          // surface wash waste

// Convert mm3 to litres (1 litre = 1,000,000 mm3)
const totalVolumeLiters = (partVolumeMm3 + supportVolumeMm3 + wasteVolumeMm3) / 1_000_000
const materialCost = totalVolumeLiters * resinCostPerLiter

// Print time: layer-by-layer based on height and layer count
// Parts are typically oriented at 45 degrees to minimise support - use diagonal height
const LAYER_HEIGHT_MM = 0.06
const timePerLayer    = variable('timePerLayer', 25)  // seconds per layer, overridable in UI

const maxDimension   = Math.max(width, height, length)
const heightAt45Deg  = maxDimension * Math.cos(45 * (Math.PI / 180))
const numberOfLayers = Math.ceil(heightAt45Deg / LAYER_HEIGHT_MM)

const printTimeHours = variable('printTime', round(numberOfLayers * timePerLayer / 3600, 2))
const machineCost    = printTimeHours * machineCostPerH

// Quantity discount via createBands
// base = 1.0 means no discount below the first threshold (under 5 units)
const getDiscount = createBands({
  5:  0.950,  // 5-9 units
  10: 0.900,  // 10-19 units
  20: 0.875,  // 20-39 units
  40: 0.850,  // 40-79 units
  80: 0.825,  // 80+ units
}, 1.0)

const quantityDiscount = getDiscount(quantity)

// Final price
const baseCost       = materialCost + machineCost
const finalUnitPrice = variable('unitPrice', round(baseCost * quantityDiscount, 2))

done(finalUnitPrice, printTimeHours)

Material variables to create before saving:

  • resinCostPerLiter - e.g. 45.00
  • machineCostPerH - machine running cost per hour, e.g. 8.00

MJF / SLS - Starter Equation

Approach: Priced on the volume of build space your part occupies (shrink-wrap volume), not just the part itself - because the surrounding powder is consumed and refreshed for every build.

// MJF / SLS Starter Equation (powder bed fusion)
// Key insight: you pay for build-chamber space, not just part volume.
// shrinkWrapVolume = tightest convex hull around the part - best proxy for
// the powder volume your part consumes in the build.

const { material, shrinkWrapVolume, volume, width, height, length } = specification
const { quantity } = requisition

const costPerCm3 = material.variables['costPerCm3']  // blended powder + machine cost per cm3
const setupCost  = material.variables['setupCost']   // fixed cost per line item

// Build space in cm3
const buildSpaceCm3 = shrinkWrapVolume / 1000

const unitPrice = variable('Unit price', round(setupCost + buildSpaceCm3 * costPerCm3, 2))

// Gate: flag for review if part exceeds build volume
const tooLarge = Math.max(width, height, length) > 380  // adjust to your machine (mm)
done(unitPrice, 0, tooLarge)

Material variables to create before saving:

  • costPerCm3 - cost per cm³ of build space (powder refresh + machine), e.g. 0.20
  • setupCost - fixed per-line cost, e.g. 15.00

PolyJet - Starter Equation

Approach: Model resin and support resin are priced separately (different costs per cm³). Support volume is estimated from the convex hull.

// PolyJet Starter Equation
// Model and support resin have different costs - both are consumables.

const { material, volume, convexHullVolume, width, height, length } = specification
const { quantity } = requisition

const modelCostPerCm3   = material.variables['modelCostPerCm3']   // model resin
const supportCostPerCm3 = material.variables['supportCostPerCm3'] // support resin
const setupCost         = material.variables['setupCost']         // head clean / tray

// Model volume and estimated support (25% of hull-part gap)
const modelVolumeCm3   = volume / 1000
const supportVolumeCm3 = ((convexHullVolume - volume) * 0.25) / 1000

const unitPrice = variable('Unit price', round(
  setupCost + modelVolumeCm3 * modelCostPerCm3 + supportVolumeCm3 * supportCostPerCm3,
  2
))

// Gate: flag for review if part exceeds build volume
const tooLarge = Math.max(width, height, length) > 490  // adjust to your machine (mm)
done(unitPrice, 0, tooLarge)

Material variables to create before saving:

  • modelCostPerCm3 - model resin cost per cm³, e.g. 1.20
  • supportCostPerCm3 - support resin cost per cm³, e.g. 0.40
  • setupCost - per-line setup cost, e.g. 12.00

CNC - Starter Equation

Approach: Machining cost is driven by the volume of material removed (bounding box minus finished part). More material to cut = more machine time and tool wear.

// CNC Starter Equation
// Cost scales with material removed, not part volume.
// A simple bracket (low removal) costs far less than a complex sculpted part.

const { material, volume, minBoundingBoxVolume, width, height, length } = specification
const { quantity } = requisition

const machiningCostPerCm3 = material.variables['machiningCostPerCm3'] // per cm3 removed
const setupCost           = material.variables['setupCost']           // fixturing + programming

// Material removed = bounding box minus finished part
const removedCm3    = Math.max(0, minBoundingBoxVolume - volume) / 1000
const removalRatio  = removedCm3 / Math.max(minBoundingBoxVolume / 1000, 0.001)

const unitPrice = variable('Unit price', round(setupCost + removedCm3 * machiningCostPerCm3, 2))

// Gate: flag for review if part is very large or removal ratio is extreme (>90%)
const needsReview = Math.max(width, height, length) > 300 || removalRatio > 0.90
done(unitPrice, 0, needsReview)

Material variables to create before saving:

  • machiningCostPerCm3 - blended machine + operator cost per cm³ removed, e.g. 0.80
  • setupCost - fixturing and programming cost per line item, e.g. 25.00

Process Pricing Patterns

Snippets for Level 1 (per-part) equations. Each is a fragment - not a complete equation. Combine with a Starter Equation or bolt it on to one you already have.

Material data - inline dictionary

Problem: You want to keep all material-specific values in the equation itself, without creating variables in Phasio per material. Define a dictionary keyed by specification.material.name.

const MATERIAL_DATA: Record<string, { density: number; costPerKg: number }> = {
  'PA12':    { density: 1.01, costPerKg: 45 },
  'TPU 95A': { density: 1.12, costPerKg: 60 },
  'PA12-GB': { density: 1.22, costPerKg: 55 },
}

const rates = MATERIAL_DATA[specification.material.name]
if (!rates) done(0)  // unknown material — triggers manual review

const { density, costPerKg } = rates

No Phasio setup required - everything lives in the equation. The tradeoff: changing a rate means editing and redeploying the equation. Use material.variables instead if you need to update rates without touching the code.

Minimum unit price

Problem: Every part should cost at least a minimum amount, regardless of how small it is.

// Place just before done() at the end of your equation.
const MIN_PRICE = 5.00  // your cost floor

const unitPrice = variable('unitPrice', round(Math.max(calculatedPrice, MIN_PRICE), 2))
done(unitPrice)

Replace calculatedPrice with the variable that holds your computed unit cost.

Quantity discount

Problem: Reward larger runs with a lower unit price. Uses createBands - no if/else chains needed.

// base = 1.0 means no discount below the first threshold (under 10 units here).
const getDiscount = createBands({
  10:  0.95,  //  10-49 units: 5% off
  50:  0.90,  //  50-99 units: 10% off
  100: 0.85,  // 100-249 units: 15% off
  250: 0.80,  // 250+ units: 20% off
}, 1.0)

const unitPrice = variable('unitPrice', round(baseCost * getDiscount(quantity), 2))
done(unitPrice)

Replace baseCost with your pre-discount cost variable.

Geometry gate - oversized part

Problem: Prevent a part that exceeds your machine bed from auto-approving. reviewRequired = true sends the order to your manual review queue instead.

// Set these to your machine's build volume (mm).
const BED_X = 250
const BED_Y = 250
const BED_Z = 250

const { width, height, length } = specification
const tooLarge = width > BED_X || height > BED_Y || length > BED_Z

// Third argument to done() is reviewRequired.
done(unitPrice, 0, tooLarge)

Lead time surcharge

Problem: Faster lead times should cost more. Lead times are configured workspace-wide in Phasio - use the exact names you defined there.

requisition.leadTime.name is the name of the lead time the customer selected at checkout.

function getLeadTimeMultiplier() {
  switch (requisition.leadTime.name) {
    case 'Economy':   return 0.85
    case 'Standard':  return 1.00
    case 'Express':   return 1.25
    case 'Overnight': return 1.60
    default:          return 1.00  // fallback for any unrecognised lead time
  }
}

done(round(unitPrice * getLeadTimeMultiplier(), 2))

You can also factor in quantity - e.g. the express surcharge is smaller on high-volume runs because overhead is spread across more parts:

function getLeadTimeMultiplier() {
  switch (requisition.leadTime.name) {
    case 'Economy':   return quantity > 100 ? 0.90 : 0.85
    case 'Standard':  return 1.00
    case 'Express':   return quantity > 100 ? 1.15 : 1.25
    case 'Overnight': return quantity > 100 ? 1.40 : 1.60
    default:          return 1.00
  }
}

done(round(unitPrice * getLeadTimeMultiplier(), 2))

unitPrice = whatever variable holds your calculated price before the lead time adjustment. Lead time names must exactly match what you configured in Phasio under Lead Times.

Machine selection

Problem: You have multiple machines with different build volumes and running costs. Automatically assign the right machine and expose it in the UI so the operator can verify.

// Define your machines: build dimensions in mm, running cost per hour.
const MACHINES = [
  { name: 'Compact',  x: 200, y: 200, z: 200, costPerHour: 3.50 },
  { name: 'Standard', x: 350, y: 350, z: 350, costPerHour: 5.00 },
  { name: 'Large',    x: 500, y: 500, z: 500, costPerHour: 8.50 },
]

const { width, height, length } = specification

// Pick the smallest (cheapest) machine the part fits in
function selectMachine() {
  return MACHINES.find(m => width <= m.x && height <= m.y && length <= m.z)
         ?? MACHINES[MACHINES.length - 1]  // fallback to largest if nothing fits
}

const machine     = selectMachine()
const machineName = variable('Machine', machine.name)  // visible in UI, operator can override

// Use machine.costPerHour wherever you calculate time x rate:
//   const machineCost = printTimeHours * machine.costPerHour

// Flag for review if part exceeds every machine's build volume
const largest    = MACHINES[MACHINES.length - 1]
const reviewRequired = width > largest.x || height > largest.y || length > largest.z
done(unitPrice, 0, reviewRequired)

See the FDM Equation in Starter Equations for a full worked example of machine selection inside a complete equation.

Raw material stock selection

Problem: Cost depends on which raw stock you cut from (bar, plate, different alloys). The operator selects the stock type per order and the equation prices accordingly.

// Define the stock your workshop carries: cost per kg and density in g/cm3.
const STOCK_OPTIONS = [
  { name: 'Aluminium Bar 20mm',   costPerKg:  8.00, densityGcm3: 2.70 },
  { name: 'Aluminium Bar 50mm',   costPerKg:  9.50, densityGcm3: 2.70 },
  { name: 'Aluminium Plate 10mm', costPerKg: 12.00, densityGcm3: 2.70 },
  { name: 'Steel Bar 30mm',       costPerKg:  3.50, densityGcm3: 7.85 },
]

// Operator selects in the UI; default is the first option
const stockName = variable('Stock type', STOCK_OPTIONS[0].name)
const stock     = STOCK_OPTIONS.find(s => s.name === stockName) ?? STOCK_OPTIONS[0]

// Use minBoundingBoxVolume as the blank size (mm3 -> cm3 -> g -> kg)
const { minBoundingBoxVolume } = specification
const blankWeightKg   = (minBoundingBoxVolume / 1000) * stock.densityGcm3 / 1000
const rawMaterialCost = variable('Raw material cost', round(blankWeightKg * stock.costPerKg, 2))

See the CNC Equation in Starter Equations for a full worked example.

Parts per build chamber

Problem: Many processes (powder bed, SLA, curing ovens, dyeing baths) run in batches. Knowing how many parts fit in a single build lets you amortise fixed batch costs correctly per part.

Tries all 6 orientations of the part and returns the maximum packing count. Works for any rectangular chamber - printer bed, autoclave, dyeing tank, whatever.

// Set your chamber dimensions in mm.
// For post-processes this might be a curing oven, dyeing bath, or blasting cabinet.
const CHAMBER_X    = 250
const CHAMBER_Y    = 250
const CHAMBER_Z    = 250
const MODEL_OFFSET = 5  // minimum gap between parts in mm

const { width, height, length } = specification

// Returns the maximum number of parts that fit across all 6 orientations
function calculatePartsPerBuild(): number {
  const orientations = [
    [width,  height, length],
    [width,  length, height],
    [height, length, width],
    [height, width,  length],
    [length, width,  height],
    [length, height, width],
  ]

  let maxParts = 0
  for (const [w, h, l] of orientations) {
    const partsX = Math.floor(CHAMBER_X / (w + MODEL_OFFSET))
    const partsY = Math.floor(CHAMBER_Y / (h + MODEL_OFFSET))
    const partsZ = Math.floor(CHAMBER_Z / (l + MODEL_OFFSET))
    maxParts = Math.max(maxParts, partsX * partsY * partsZ)
  }

  return Math.max(1, maxParts)  // always at least 1
}

const partsPerBuild = calculatePartsPerBuild()
const buildCapacity = variable('Parts per build', partsPerBuild)  // overridable in UI

// Amortise fixed batch cost across the parts in this build:
const BATCH_COST    = material.variables['batchCost']   // fixed cost per build run
const perPartCost   = variable('unitPrice', round(BATCH_COST / Math.min(buildCapacity, quantity), 2))
done(perPartCost)

In a post-process equation, replace material.variables['batchCost'] with variable('Batch cost', 50.00) since post-processes don't always have a material attached. The calculatePartsPerBuild function is identical - just set CHAMBER_X/Y/Z to the post-process equipment dimensions.

Customer discount

Problem: Certain accounts get a negotiated discount. Look up the customer's organisation name and apply it automatically.

customer.organisationName is the Organisation Name field on the Phasio contact record.

// Names must exactly match the Organisation Name in Phasio.
const CUSTOMER_DISCOUNTS: Record<string, number> = {
  'Acme Industries': 0.10,  // 10% off
  'Megacorp Ltd':    0.15,  // 15% off
}

const discount  = CUSTOMER_DISCOUNTS[customer.organisationName ?? ''] ?? 0
const unitPrice = variable('unitPrice', round(baseCost * (1 - discount), 2))
done(unitPrice)

Replace baseCost with your calculated price before the discount. Add as many entries as you need.

Post-Process Pricing Patterns

Snippets for Level 2 (post-process) equations. These run once per part if the customer selects that post-process. The processPricing object holds the result of the Level 1 equation for this part.

Always wrap your final price in variable() - it lets the operator override the post-process cost per order in the Phasio UI.

Flat fee per part

Problem: The post-process costs a fixed amount regardless of part size - e.g. a media blast, a tap operation, a marking step.

const cost = variable('Post-process cost', 8.00)
done(cost)

Percentage of part price

Problem: Post-process cost scales with part value - e.g. painting a complex expensive part warrants more time and care.

processPricing.price is the price returned by the Level 1 equation for this part.

const PERCENTAGE = variable('Percentage Price increase', 0.15)  // 15% of the Level 1 part price
const unitPrice  = variable('Edit the final unit price', round(processPricing.price * PERCENTAGE, 2))

done(unitPrice)

Area-based cost

Problem: The post-process covers the part's surface - coating, painting, anodising. Cost should scale with surface area.

specification.area is in mm².

const RATE_PER_MM2 = variable('Rate per mm2', 0.002)   // cost per mm2
const MINIMUM      = variable('Minimum charge', 5.00)  // minimum charge per part

const cost      = specification.area * RATE_PER_MM2
const unitPrice = variable('unitPrice', round(Math.max(cost, MINIMUM), 2))
done(unitPrice)

1 000 mm² at 0.002/mm² = 2.00 → floored to the minimum of 5.00.

Color-conditional pricing

Problem: Different colors require different dye baths or paint mixes - each has its own setup cost.

// Use exact color names as configured in your Phasio materials.
const COLOR_COSTS: Record<string, number> = {
  'Black': 5.00,
  'White': 5.00,
  'Red':  15.00,
  'Blue': 15.00,
}

const color     = specification.color ?? 'Black'
const cost      = COLOR_COSTS[color] ?? 20.00  // fallback for any color not in the list
const unitPrice = variable('unitPrice', cost)
done(unitPrice)

Use a Level 1 variable in Level 2

Problem: Post-process cost depends on something calculated in Level 1 - e.g. UV curing time scales with print time.

Expose the value with variable() in Level 1. Access it with processPricing.variables in Level 2.

// In your Level 1 equation, you must have:
//   const printTimeHours = variable('printTime', round(..., 2))

const printTimeHours    = processPricing.variables['printTime'] ?? 0
const POST_PROCESS_RATE = variable('Rate per hour', 2.50)
const unitPrice = variable('unitPrice', round(printTimeHours * POST_PROCESS_RATE, 2))

done(unitPrice)

Order Level Pricing Patterns

Order level pricing runs once per order and sees all parts together. Use it whenever a price depends on the combination of parts in the cart - not just a single part in isolation.

You add charges or discounts using addLineItem({ name, price }). Positive price = charge. Negative price = discount.

Minimum order value

Problem: Every order must be worth at least a certain amount.

// Set your minimum order value
const MINIMUM = 100

// subtotal = sum of all part prices before order-level adjustments
if (subtotal < MINIMUM) {
  addLineItem({
    name: 'Minimum order fee',
    price: MINIMUM - subtotal  // top up to the minimum
  })
}

Customer orders 40 of parts → invoice shows a 60 "Minimum order fee".

Minimum price per material

Problem: Each material has a setup cost. A single small part in PA11 should still cover the minimum run cost for that material.

// Set your minimum per material - use the exact material names from your Phasio setup
const MINIMUMS: Record<string, number> = {
  'PA12': 48,
  'PA11': 69,
  'TPU':  69,
}

// Sum the total price of all parts per material
const totals: Record<string, number> = {}

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

// Add a top-up fee for any material below its minimum
Object.keys(totals).forEach(mat => {
  if (totals[mat] < MINIMUMS[mat]) {
    addLineItem({
      name: `Minimum order fee — ${mat}`,
      price: round(MINIMUMS[mat] - totals[mat], 2)
    })
  }
})

Order has 1x PA11 part priced at 20 → invoice shows "Minimum order fee — PA11: 49".

Minimum post-processing cost

Problem: A post-process (e.g. dyeing, vapor smoothing) has a minimum batch cost. Even if only one part uses it, the minimum applies.

// Set your minimum per post-process - use the exact post-process names from your Phasio setup
const PP_MINIMUMS: Record<string, number> = {
  'Black Dye':    20,
  'Vapor Smooth': 80,
}

// Sum post-process prices across all parts
const ppTotals: Record<string, number> = {}

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

// Add a top-up fee for any post-process below its minimum
Object.keys(ppTotals).forEach(ppName => {
  if (ppTotals[ppName] < PP_MINIMUMS[ppName]) {
    addLineItem({
      name: `Minimum charge — ${ppName}`,
      price: round(PP_MINIMUMS[ppName] - ppTotals[ppName], 2)
    })
  }
})

1 part with Vapor Smooth priced at 15 → invoice shows "Minimum charge — Vapor Smooth: 65".

Minimum post-processing cost - grouped by color

Problem: The minimum applies per color, not per post-process. A blue-dyed part and a black-dyed part each trigger their own minimum - they don't pool together.

// Set your minimum per post-process - use the exact post-process names from your Phasio setup
const PP_MINIMUMS: Record<string, number> = {
  'Dyeing': 50,
}

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

parts.forEach(({ specification, price }) => {
  if (!specification.postProcessing) return
  specification.postProcessing.forEach(pp => {
    if (!PP_MINIMUMS[pp.name]) return

    // Key combines post-process name + color: "Dyeing_blue" vs "Dyeing_black"
    const groupKey = `${pp.name}_${specification.color || 'default'}`

    if (!ppTotals[groupKey]) {
      ppTotals[groupKey] = {
        label: `${pp.name}${specification.color ? ` (${specification.color})` : ''}`,
        total: 0,
        min: PP_MINIMUMS[pp.name],
      }
    }
    ppTotals[groupKey].total += price
  })
})

// Add a top-up fee for any color group below its minimum
Object.keys(ppTotals).forEach(key => {
  const { label, total, min } = ppTotals[key]
  if (total > 0 && total < min) {
    addLineItem({
      name: `Minimum charge — ${label}`,
      price: round(min - total, 2)
    })
  }
})

5 black parts pool together. 1 blue part is checked separately. Invoice shows "Minimum charge — Dyeing (blue): 42".

Volume discount per material

Problem: Reward customers who order a lot of one material. The more they order, the bigger the discount.

createBands returns the discount for the highest threshold the total exceeds. Below all thresholds it returns 0.

// Define discount tiers: { threshold: discount as a decimal }
const getDiscount = createBands({
   500: 0.02,   // 2% off
  1000: 0.05,   // 5% off
  2000: 0.08,   // 8% off
  5000: 0.10,   // 10% off
})

// Sum total price per material
const totals: Record<string, number> = {}

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

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

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

Shipping cost (bin packing)

Problem: Estimate shipping by working out how many boxes are needed to fit all parts, and charging the carrier cost per box.

This is the most involved snippet. It packs parts into the fewest boxes using a first-fit decreasing algorithm - largest parts first, then fill remaining space.

// Box sizes: external dimensions in mm, cost in your currency. Adjust to your carrier rates.
const PACKING_OFFSET_MM = 25  // wall + void padding deducted from each box

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 },
]

// Returns true if a part fits inside a box chamber in any of the 6 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]]
    .some(([a,b,c]) => a <= cX && b <= cY && c <= cZ)
}

// Returns the usable interior dimensions of a box (external minus padding)
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 }
}

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

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

  // Sort largest-volume items first (First Fit Decreasing heuristic)
  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 in 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 largest box the item fits in
    if (!placed) {
      let chosen = BOXES[BOXES.length - 1]
      for (let i = BOXES.length - 1; i >= 0; 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 }
}

const allItems: Item[] = []

parts.forEach(({ specification, requisition }) => {
  const { width, height, length, volume } = specification
  const density = 1.1           // g/cm3 - covers most plastics. Adjust per material.
  const weightG = (volume / 1000) * density  // volume is in mm3, convert to cm3 first
  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 box → invoice shows "Shipping (1x M, 1x S): 26".

Last updated on

On this page