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
Decimaleverywhere. Value content is a ratio of money figures; a singlefloatin an adjusted value or a component price introduces rounding drift that compounds through every tier of the roll-up. UseDecimalfor 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
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat) 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.
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:
- Method isolation. Evaluate the same BOM under
TRANSACTION_VALUEandNET_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. - 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.
- 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. - Decimal exactness. Independently recompute
(basis − VNM) / basisfor the root withDecimaland assert it matchesevaluateto four places underROUND_HALF_UP. Any mismatch means afloatentered anadjusted_valueornet_costfield upstream. - Quantity composition. Change a sub-assembly’s
quantityfrom 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. - 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_costfield 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_valueof 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.
Related
- Testing USMCA tariff-shift rules in Python
- Implementing rule of origin checks in Python
- Rule of Origin Logic Engines
- Duty Formula Calculation Frameworks
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.