Calculating regional value content with BOM hierarchies

Regional value content only means something when it is rolled up over a real multi-tier bill of materials: a finished good is built from sub-assemblies, each sub-assembly from components, and each component carries its own originating or non-originating value that must aggregate upward before you can assert an RVC percentage on a certificate of origin. Compute it flat — treating every part as a leaf — and you either double-count intermediate values or miss the originating content locked inside a qualifying sub-assembly, and the resulting percentage will not survive a CBP verification. This page gives you a single recursive engine that walks a BOM hierarchy, applies both the transaction-value and net-cost methods from USMCA Article 4.5, tracks originating versus non-originating value in Decimal at every node, and emits an auditable roll-up you can hand to the rule of origin checks that gate a preference claim.

Prerequisites

This engine assumes a specific data shape and a few upstream guarantees. Confirm each before applying it:

  • Python 3.10+ with Decimal everywhere. Value content is a ratio of money figures; a single float in an adjusted value or a component price introduces rounding drift that compounds through every tier of the roll-up. Use Decimal for all monetary fields and quantize only at the reporting boundary, never mid-recursion.
  • A resolved BOM with per-node quantities and values. Each node needs its adjusted value (transaction value adjusted to an FOB or ex-works basis per the applicable agreement), a per-unit or extended cost, and — for purchased inputs — an origin determination. That determination is the output of an origin check, not an input you invent here; the rule of origin logic engine supplies it.
  • A consistent value basis across siblings. Transaction value and net cost cannot be mixed within a single RVC calculation for one good. Pick the method the certifier is claiming and apply it uniformly; this engine enforces that by parameterising the method once at the root.
  • Non-originating value already classified. Every leaf must be tagged originating or non-originating before roll-up. An untagged leaf is a data-quality failure, not a zero — treat it as non-originating and flag it, never silently include it as originating.
  • Structured logging configured (stdlib logging with the %(asctime)s | %(levelname)s | %(name)s | %(message)s format) so each node’s contribution is greppable when a broker reconstructs the calculation.

Implementation

One recursive function owns the whole roll-up. Each node reports the total value it contributes and how much of that value is non-originating; a qualifying sub-assembly contributes its entire value as originating and stops the recursion, because once an intermediate good qualifies under its own rule it rolls up as wholly originating material.

import logging
from dataclasses import dataclass, field
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Optional

logging.basicConfig(
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    level=logging.INFO,
)
logger = logging.getLogger("rvc_rollup")


class RvcMethod(Enum):
    TRANSACTION_VALUE = "TV"   # USMCA Art. 4.5(2): (AV - VNM) / AV
    NET_COST = "NC"            # USMCA Art. 4.5(3): (NC - VNM) / NC


@dataclass(frozen=True)
class BomNode:
    part_number: str
    description: str
    quantity: Decimal                      # units of this node per parent unit
    adjusted_value: Decimal                # per-unit AV (FOB/ex-works basis)
    net_cost: Decimal                      # per-unit net cost, TV excluded costs removed
    originating: Optional[bool] = None     # None => untagged leaf, treated as non-originating
    children: tuple["BomNode", ...] = field(default_factory=tuple)


@dataclass(frozen=True)
class Rollup:
    part_number: str
    total_value: Decimal                   # extended value this node contributes to parent
    non_originating_value: Decimal         # VNM contained in that contribution
    qualified_subassembly: bool


class RvcEngine:
    """Recursive regional-value-content roll-up over a multi-tier BOM."""

    def __init__(self, method: RvcMethod, threshold: Decimal):
        self._method = method
        self._threshold = threshold        # e.g. Decimal("0.60") TV, Decimal("0.50") NC

    def _unit_basis(self, node: BomNode) -> Decimal:
        # The denominator basis for one unit under the chosen method.
        return (
            node.adjusted_value
            if self._method is RvcMethod.TRANSACTION_VALUE
            else node.net_cost
        )

    def _rollup_node(self, node: BomNode) -> Rollup:
        basis = self._unit_basis(node)
        extended = (basis * node.quantity)

        # Leaf: purchased input. Its value is wholly non-originating unless tagged True.
        if not node.children:
            originating = node.originating is True
            if node.originating is None:
                logger.warning(
                    "Untagged leaf %s treated as non-originating.", node.part_number
                )
            vnm = Decimal("0") if originating else extended
            return Rollup(node.part_number, extended, vnm, qualified_subassembly=False)

        # Intermediate good: roll up children first.
        child_total = Decimal("0")
        child_vnm = Decimal("0")
        for child in node.children:
            r = self._rollup_node(child)
            child_total += r.total_value
            child_vnm += r.non_originating_value

        # Does this sub-assembly itself qualify on its own contained content?
        sub_rvc = (
            (child_total - child_vnm) / child_total
            if child_total > 0
            else Decimal("0")
        )
        qualifies = sub_rvc >= self._threshold
        if qualifies:
            # A qualifying intermediate good rolls up as WHOLLY originating material:
            # its full extended value carries zero VNM to the parent.
            logger.info(
                "Sub-assembly %s qualifies (RVC=%s) -> wholly originating.",
                node.part_number, sub_rvc.quantize(Decimal("0.0001")),
            )
            return Rollup(node.part_number, extended, Decimal("0"), True)

        # Non-qualifying: pass the contained non-originating value upward, scaled to
        # this node's extended value so quantities compose correctly across tiers.
        scale = extended / child_total if child_total > 0 else Decimal("0")
        return Rollup(node.part_number, extended, child_vnm * scale, False)

    def evaluate(self, root: BomNode) -> tuple[Decimal, Rollup]:
        rollup = self._rollup_node(root)
        av_or_nc = self._unit_basis(root) * root.quantity
        vnm = rollup.non_originating_value
        rvc = (av_or_nc - vnm) / av_or_nc if av_or_nc > 0 else Decimal("0")
        rvc = rvc.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
        logger.info(
            "%s RVC (%s) = %s (threshold %s) -> %s",
            root.part_number, self._method.value, rvc, self._threshold,
            "QUALIFIES" if rvc >= self._threshold else "FAILS",
        )
        return rvc, rollup


def rvc_percent(rvc: Decimal) -> Decimal:
    """Present the ratio as a certificate-ready percentage, two places."""
    return (rvc * Decimal("100")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

The load-bearing decision is the qualifying-sub-assembly branch. When an intermediate good clears the threshold on its own contained content, it rolls up carrying zero non-originating value, exactly as a wholly obtained originating material would. That single rule is what separates a correct hierarchical RVC from a flat one, and it is where naive implementations silently under- or over-count.

Value roll-up over a three-tier bill of materials A finished good sits at the top with adjusted value USD 1,000. It has two sub-assemblies. The left sub-assembly, value USD 600, is built from two components: an originating component worth USD 400 carrying zero non-originating value, and a non-originating component worth USD 200. Its internal RVC is 66 percent, above the 60 percent threshold, so it qualifies and rolls up as wholly originating, contributing zero non-originating value to the finished good. The right sub-assembly, value USD 400, is a single non-originating input contributing its full USD 400 as non-originating value. At the root, non-originating value totals USD 400 against adjusted value USD 1,000, giving a finished-good RVC of 60 percent, which meets the threshold and qualifies. Finished good AV = USD 1,000 VNM = 400 · RVC 60% Sub-assembly A value 600 · RVC 66% qualifies → wholly originating Input B value 400 · non-originating contributes VNM 400 Component A1 value 400 · originating VNM 0 Component A2 value 200 · non-orig VNM 200 Roll-up A: (600 − 200) / 600 = 66% ≥ 60% → carries VNM 0 upward Root: (1000 − 400) / 1000 = 60% ≥ 60% → finished good qualifies
A qualifying sub-assembly rolls up as wholly originating (VNM 0); only the non-originating Input B passes value into the finished good's VNM, yielding a 60% RVC at the root.

Verification steps

Run these checks against a representative BOM before trusting the roll-up on a certificate:

  1. Method isolation. Evaluate the same BOM under TRANSACTION_VALUE and NET_COST. The two RVC figures must differ (net cost strips profit and certain excluded costs from the basis) and each must use its own threshold — 60% for transaction value, 50% for net cost under the standard USMCA good. If they come out identical, the net-cost fields are echoing adjusted value.
  2. Flat versus hierarchical parity trap. Compute a deliberately flat RVC by tagging every leaf and summing without the qualifying-sub-assembly rule, then compare to the hierarchical result. They should diverge whenever any intermediate good qualifies on its own — if they match, the recursion is not collapsing qualifying sub-assemblies to originating.
  3. Untagged-leaf safety. Feed one component with originating=None. Confirm it is logged and counted as non-originating, and that it drags the RVC down rather than being silently omitted.
  4. Decimal exactness. Independently recompute (basis − VNM) / basis for the root with Decimal and assert it matches evaluate to four places under ROUND_HALF_UP. Any mismatch means a float entered an adjusted_value or net_cost field upstream.
  5. Quantity composition. Change a sub-assembly’s quantity from 1 to 2 and confirm both its extended value and the VNM it passes upward scale linearly. A roll-up that scales value but not non-originating value is the classic multi-tier bug.
  6. Threshold boundary. Construct a BOM whose sub-assembly lands exactly on the threshold. Confirm the >= comparison qualifies it, matching the agreement’s “not less than” language, before you compare against the duty formula calculation frameworks that consume the preference outcome.

Edge cases & gotchas

  • De minimis is not modelled here. USMCA allows a good to qualify despite non-originating materials that fail their tariff-shift rule if their value stays under the de minimis threshold (generally 10% of adjusted value). This engine computes RVC only; wire the de minimis test in beside it and do not let a passing RVC mask a de minimis failure on an individual material.
  • Fungible materials and averaging. When originating and non-originating fungible inputs are commingled, you may elect an inventory-management method (FIFO, LIFO, or averaging) rather than physical tracing. That election changes each leaf’s originating tag before it ever reaches this roll-up — resolve it upstream, because retagging mid-recursion breaks determinism.
  • Net-cost basis leakage. Net cost must have sales promotion, royalties, shipping, and non-allowable interest removed. If your net_cost field is really total cost, the denominator is inflated and RVC is understated, quietly failing goods that should qualify.
  • Intermediate materials designation. A producer may designate a self-produced intermediate material as originating to simplify the calculation, but only if that intermediate independently satisfies its own origin rule. That is exactly the qualifying-sub-assembly branch — never designate an intermediate as originating without running its own rule of origin check first.
  • Currency mixing. Adjusted values sourced in different currencies must be converted to a single reporting currency at the correct rate before roll-up, following ISO 4217 minor units. Summing mixed currencies produces a meaningless ratio; convert at ingestion, not inside evaluate.
  • Zero-value basis. A node with adjusted_value of zero divides by zero. The guards return zero RVC, but a legitimately zero-value node usually signals missing data — treat a zero basis as a quarantine condition, not a qualifying result.

Up: Rule of Origin Logic Engines

Authoritative references: USMCA Chapter 4 and Article 4.5 (Regional Value Content); USMCA Uniform Regulations; 19 CFR Part 182; WCO HS 2022 Nomenclature; ISO 4217 currency minor units.