Skip to main content

Bundle pricing & revenue proration

A bundle is a sales-and-marketing wrapper, not an inventory-bearing product. When a bundle sells, SKU.io doesn't record revenue against the bundle itself — it expands the bundle into its component lines and splits the bundle's amount across them. Each component line is a real revenue record; there is no bundle "parent" line carrying money.

This page explains the model behind that split: how the split basis is chosen (cost, then price, then quantity), the fallback that guarantees no component is ever handed $0, why editing a bundle never rewrites orders that already happened, and how a second correction at fulfillment re-anchors each component's revenue to the real cost it consumed.

If you are new to the distinction between the two composite types, read Kits vs bundles first — kits hold their own stock and sell as one line, whereas bundles hold no stock and always explode into components.

The problem proration solves

A bundle line arrives with one number: the price the customer paid for the whole bundle. Profitability reporting, however, is per component — each component product needs its own slice of that revenue so its margin can be computed against its own cost. If the slices are chosen badly (for example, splitting purely by a stale retail price while costs tell a different story), individual components can show fabricated losses even when the bundle as a whole is healthy. Proration is the rule that turns one bundle amount into per-component amounts that add back up exactly and reflect reality.

How the split basis is chosen

The basis is decided on the bundle product by Product::getBundleProrationBasis(), which walks its components in a strict order and picks the first methodology whose data is complete:

OrderBasisChosen whenEach component's share is proportional to
1CostEvery component has unit_cost greater than 0its unit_cost × recipe quantity
2PriceNot all costs are set, but every component has a price greater than 0its price × recipe quantity
3QuantityNeither costs nor prices are completeits recipe quantity alone

Cost is preferred because it anchors reported margins to what the goods actually cost, so a component can never appear to lose money purely because its retail price is stale or disproportionate. Price is the fallback for catalogs that haven't filled in costs. Quantity is the last resort — see the next section.

The chosen basis isn't just used and forgotten. It's persisted on each component line at sales_order_lines.bundle_proration_basis (cost, price, or quantity) so the order — and the transparency UI on it — can show exactly how that line's amount was derived, months later, without recomputing anything.

The zero-cost, zero-price fallback

The Quantity basis isn't just a tidy default — it's a safety net. Before this rule existed, a component that had neither a cost nor a price could be allocated $0, silently deleting part of the bundle's revenue. The quantity fallback splits the bundle amount purely in proportion to each component's recipe quantity, so every component always receives a non-zero share and the bundle's full amount is preserved.

The extreme case — all components have both zero cost and zero price — therefore still resolves cleanly to a quantity-proportional split. It never allocates $0 to a component and never drops revenue on the floor. Two consequences follow from a genuinely cost-free group:

  • The fulfillment-time correction described below is skipped when the group's total COGS is 0 (there is nothing to re-anchor to), so the document split stands.
  • Component lines created before this basis was tracked have bundle_proration_basis = null. The order's allocation UI reads that as legacy (price-based) rather than pretending it was a deliberate choice.

Exact-sum reconciliation

Splitting one amount into several rounded slices risks the slices not adding back to the original by a cent or two. The ProrateBundleAmountAcrossComponents action closes that gap. It computes each component's per-unit amount from its ratio (rounded to 4 decimal places), then re-derives the amount of the component with the largest line total so that:

Σ(component amount × recipe quantity) == bundle amount (exact at 4dp)

Amounts are per-unit to match how a sales order line stores money — a component line's total is its amount × quantity. Assigning the rounding remainder to the largest line keeps the relative distortion as small as possible. The result is a map of product_id → { amount, basis } that the expansion applies to the component lines.

Bundle expansion on a sales order

When a bundle is ordered, SKU.io replaces the bundle line with component lines and stamps each with a shared bundle_id (linking the siblings) plus its prorated per-unit amount. There is no bundle parent revenue line — the components are the revenue.

This expansion happens at six points, and every one of them applies the same proration once, at expansion time, then leaves the amounts alone:

Where a bundle gets expandedTrigger
Sales order creationA bundle is added to a new order
Sales order create/update from payloadOrder built or edited from a saved payload
Channel syncAn order comes in from a connected channel
API sales-order updateA bundle line is changed through the API
Inventory-health repairA previously unexpanded bundle line is fixed up
Shopify order update syncA bundle line changes on a synced Shopify order

Because the amounts are written once and never recomputed on the fly, the per-component revenue you see on an order is stable. The full sales-order mechanics around expansion — backorder handling, warehouse locks, mutator guards, and the repair path — belong to the Sales Orders area and are only summarized here. See Manage order lines for how expanded component lines appear and behave on an order.

Editing a bundle never rewrites past orders

Proration and every later correction operate on the persisted component lines — grouped by their stored bundle_id — never on the bundle's current recipe. So changing a bundle's components, quantities, or costs today doesn't reach back and rewrite orders that already shipped or were placed. Historical lines keep the amounts and basis they were expanded with.

This is deliberate and matters in three ways:

  • A bundle whose recipe changed since an old order still reports that order exactly as it was sold. The component list on the order can legitimately differ from the bundle's list today.
  • If a component product was later swapped out of the recipe, the historical order may reference component SKUs that are no longer part of the current bundle — that's correct, not drift to be "fixed."
  • If the same bundle appears on two separate lines of one order, both expansions share the one bundle_id and are grouped together for any correction. Margins stay uniform across all of those lines, which is the accepted behavior.

To change how future orders split, edit the bundle in the Workshop and make sure component costs and prices are populated — see Build bundles & kits in the Workshop and Set a product's price.

Revenue correction at COGS finalization

The split at expansion is a best estimate from the numbers known at order entry. The real cost of each component isn't known until fulfillment consumes actual FIFO layers. A second pass — the bundle group revenue correction — runs when financials are calculated (SalesOrderLineFinancialManager::calculateAndCacheFinancials) and re-allocates each bundle group's net revenue in proportion to each component's actual COGS.

Key properties of this correction:

  • It groups component lines by sales_order_id + bundle_id, sums the group's net revenue and COGS, then re-splits revenue so each line's share tracks its cost. The largest-COGS line absorbs the rounding remainder, so the group's revenue total is preserved exactly at 4 decimal places.
  • It touches only the cached SalesOrderLineFinancial.revenue figure. The sales order documents — sales_order_lines.amount and per-line tax — are never mutated. What the customer was invoiced is untouched; only the internal margin attribution moves.
  • It's self-converging. Before fulfillment it follows the weighted-average cost estimate; once fulfillment finalizes FIFO costs, the normal financials-invalidation pipeline re-runs it and re-anchors revenue to the real cost consumed.
  • It skips any group whose total COGS is 0 (nothing to anchor to) and any single-line group (the correction would be the identity).

Each corrected figure records how it was derived at sales_order_line_financials.revenue_allocation_basis: document (straight from the order line amount) or cost_corrected (re-allocated by COGS). That flag drives the allocation breakdown shown on the order's Profit tab. The full profitability and financials behavior lives in the Financials area — this page only explains the seam. For how FIFO cost layers are consumed at fulfillment, see FIFO layers & COGS.

Before you begin

Next steps

Last verified: