Standardizing budget cost codes across Procore and Sage 300
Reconciling cost data between a project management platform and an accounting ERP fails the moment a budget code crosses the boundary in the wrong shape. Procore exposes cost codes as flexible, variable-length strings — 03-20-15-00, CSI.03.200.1500, 03_20_15_00 — while Sage 300 stores them as fixed-width alphanumeric G/L segments with hard character limits, a strict segment count, and zero tolerance for lowercase letters or stray punctuation. This page covers exactly one slice of that integration: how to parse a heterogeneous Procore cost code, normalize it deterministically into a Sage 300 segment string, and route the result so a non-conforming record never silently corrupts the ledger with a duplicate posting or a truncated account number. It is the financial counterpart to WBS mapping strategies — where WBS mapping aligns schedule identifiers, this gate aligns cost identifiers — and it sits inside the broader budget code standardization workflow that the parent Construction Data Architecture & Taxonomy layer depends on for auditable cost aggregation.
Key rules and specification
Encode every Procore-to-Sage constraint as an explicit, testable rule rather than a hopeful string concatenation. The Sage 300 segment widths shown here ((4, 4, 4)) are illustrative — read the real values from your company database before hardcoding them, because they vary per install.
| Rule | Specification | Why it matters downstream |
|---|---|---|
| Delimiter normalization | Procore mixes ., -, _, and spaces; collapse any run to a single - |
One canonical separator makes segment splitting deterministic |
| Character set | Sage 300 G/L segments accept [A-Z0-9] only |
Lowercase or punctuation makes the ERP reject the whole batch |
| Segment widths | Fixed per segment (e.g. (4, 4, 4)), sourced from GLACCOUNT |
A segment posted at the wrong width lands on the wrong account |
| Segment depth | Must not exceed the configured segment count | Extra segments mean the mapping is wrong, not paddable |
| Padding direction | Left zero-fill short numeric segments (zfill, not ljust) |
Right-padding renumbers the code and collides distinct accounts |
| Truncation policy | Over-width segments lose data; never auto-post them | Silent truncation drifts cost rollups against the original budget |
| Idempotency | Identical input must yield identical output | A frozen result model prevents duplicate ledger postings on retry |
Two rules carry the most weight. Padding direction is the one most teams get backwards: a four-wide segment receiving 30 must become 0030, not 3000 — right-padding silently turns division 30 into account 3000 and posts real money to the wrong line. And truncation policy is where data loss hides: when a Procore segment is wider than its Sage target, dropping characters produces a syntactically valid Sage code that points at the wrong account, so a truncated mapping must be flagged and held, never auto-committed.
Production translation and routing
The module below parses and normalizes the Procore code, then returns a frozen Pydantic v2 model whose routing decision follows the pipeline’s canonical confidence bands: an exact, lossless mapping (1.0) auto-routes at or above 0.92, a deterministically padded mapping (0.80) falls into the 0.75–0.92 human-review band, and any lossy or structurally broken result is quarantined regardless of confidence — exactly the same contract the schema validation gates use elsewhere in the system.
from __future__ import annotations
import logging
import re
from typing import Literal
from pydantic import BaseModel, Field, field_validator
logger = logging.getLogger("construction.budget_code_sync")
MappingStatus = Literal[
"EXACT", "PADDED", "TRUNCATED", "SEGMENT_OVERFLOW", "INVALID_CHAR", "EMPTY"
]
RoutingAction = Literal["auto_route", "human_review", "quarantine"]
# Sage 300 G/L segment widths for THIS company database. Confirm against the
# GLACCOUNT table or your ERP administrator before relying on these values.
SAGE_SEGMENT_WIDTHS: tuple[int, ...] = (4, 4, 4)
_DELIMITERS = re.compile(r"[.\-_ ]+") # Procore mixes . - _ and spaces
_ALLOWED_SEGMENT = re.compile(r"^[A-Z0-9]+$") # Sage 300 rejects everything else
class CostCodeMapping(BaseModel):
"""Frozen, deterministic Procore -> Sage 300 cost-code translation result."""
model_config = {"frozen": True, "extra": "forbid"}
procore_code: str
sage_code: str
segments: tuple[str, ...]
status: MappingStatus
confidence: float = Field(ge=0.0, le=1.0)
detail: str | None = None
@field_validator("procore_code", mode="before")
@classmethod
def _coerce_str(cls, v: object) -> str:
# The Procore API occasionally returns numeric-looking codes as ints.
return v if isinstance(v, str) else str(v)
@property
def routing(self) -> RoutingAction:
# A lossy or malformed mapping is never safe to post, whatever the score.
if self.status in ("TRUNCATED", "SEGMENT_OVERFLOW", "INVALID_CHAR", "EMPTY"):
return "quarantine"
if self.confidence >= 0.92:
return "auto_route"
if self.confidence >= 0.75:
return "human_review"
return "quarantine"
def translate_cost_code(procore_code: str) -> CostCodeMapping:
"""Normalize a Procore cost code into a Sage 300 segment string."""
raw = (procore_code or "").strip().upper()
if not raw:
return CostCodeMapping(
procore_code=procore_code, sage_code="", segments=(),
status="EMPTY", confidence=0.0, detail="Empty or non-string input.",
)
# 1. Collapse every delimiter run to a single hyphen, then split.
normalized = _DELIMITERS.sub("-", raw)
segments = tuple(seg for seg in normalized.split("-") if seg)
# 2. Character compliance: one bad segment fails the whole code.
for seg in segments:
if not _ALLOWED_SEGMENT.match(seg):
return CostCodeMapping(
procore_code=procore_code, sage_code="", segments=segments,
status="INVALID_CHAR", confidence=0.0,
detail=f"Segment {seg!r} contains characters Sage 300 rejects.",
)
# 3. Depth: more segments than Sage allows is a mapping error, not a pad.
if len(segments) > len(SAGE_SEGMENT_WIDTHS):
return CostCodeMapping(
procore_code=procore_code, sage_code="-".join(segments), segments=segments,
status="SEGMENT_OVERFLOW", confidence=0.0,
detail=f"{len(segments)} segments exceed the "
f"{len(SAGE_SEGMENT_WIDTHS)}-segment Sage 300 structure.",
)
# 4. Width enforcement: left zero-fill short segments, flag any truncation.
final: list[str] = []
truncated = padded = False
for i, seg in enumerate(segments):
width = SAGE_SEGMENT_WIDTHS[i]
if len(seg) > width:
final.append(seg[:width])
truncated = True
elif len(seg) < width:
final.append(seg.zfill(width)) # 30 -> 0030, never 3000
padded = True
else:
final.append(seg)
# 5. Backfill omitted trailing segments with zeros to reach fixed depth.
while len(final) < len(SAGE_SEGMENT_WIDTHS):
final.append("0" * SAGE_SEGMENT_WIDTHS[len(final)])
padded = True
sage_code = "-".join(final)
if truncated:
return CostCodeMapping(
procore_code=procore_code, sage_code=sage_code, segments=tuple(final),
status="TRUNCATED", confidence=0.5,
detail="A segment exceeded its Sage 300 width; characters were dropped.",
)
if padded:
return CostCodeMapping(
procore_code=procore_code, sage_code=sage_code, segments=tuple(final),
status="PADDED", confidence=0.80,
detail="Short or omitted segments were zero-filled to fixed width.",
)
return CostCodeMapping(
procore_code=procore_code, sage_code=sage_code, segments=tuple(final),
status="EXACT", confidence=1.0,
)
def sync_cost_code(procore_code: str) -> dict[str, str]:
"""Translate, route by confidence band, and emit a structured audit record."""
mapping = translate_cost_code(procore_code)
action = mapping.routing
if action == "auto_route":
logger.info("POST %s -> %s", mapping.procore_code, mapping.sage_code)
elif action == "human_review":
logger.warning("REVIEW %s -> %s (%s)",
mapping.procore_code, mapping.sage_code, mapping.detail)
else:
logger.error("QUARANTINE %s (%s)", mapping.procore_code, mapping.detail)
# model_dump_json gives an immutable, replayable record for the audit trail;
# only sage_code (never procore_code) should reach an accounting endpoint.
return {"action": action, "record": mapping.model_dump_json()}Common mistakes and gotchas
- Right-padding numeric segments. Reaching for
str.ljust(width, "0")turns segment30into3000instead of0030, posting against an account that already exists for a different trade. Numeric Sage segments must be left-filled withzfill, so the original magnitude — and the account it maps to — is preserved. - Auto-posting truncated codes. Dropping over-width characters yields a valid-looking Sage code, so a naive pipeline marks it compliant and commits it. The amount lands on the wrong line and the variance only surfaces at month-end. Truncation must downgrade the result to
quarantineand hand the raw payload to the fallback alert routing queue for a controller to remap by hand. - Hardcoding segment widths.
SAGE_SEGMENT_WIDTHSdiffers per Sage 300 company database; baking in(4, 4, 4)from one install silently mis-segments codes on the next. Read the widths fromGLACCOUNTat startup, and passprocore_codethrough the typed model rather than to the ERP directly — the same currency- and type-coercion discipline that keeps cost aggregations numeric instead of string-zeroed.
Integration pointer
This translator runs at the ingestion boundary between Procore and Sage 300, immediately before any posting job. Upstream, the codes it receives carry the MasterFormat structure resolved by how to map CSI MasterFormat to custom WBS codes in Python; keep that mapping and these segment widths in lockstep so a code that is valid for the schedule is also valid for the ledger. Downstream, every quarantined or review-flagged result becomes an event for the error handling protocols layer to retry or escalate, and only auto_route records — emitted as immutable model_dump_json payloads — ever reach the accounting endpoint. The same confidence bands and structured-audit pattern govern validating extracted RFI fields against custom JSON schemas, so a team that has internalized one gate already understands this one.
Frequently asked questions
Why left-pad (zfill) Procore segments instead of right-padding?
Sage 300 cost segments are positional numbers, so 0030 and 3000 are different accounts. Left zero-fill preserves the original magnitude — 30 stays division 30 — whereas right-padding (ljust) multiplies it and posts to an unrelated account that may already exist for another trade. Use zfill(width) for numeric segments and reserve right-fill only for genuinely left-justified alphanumeric segment schemes, which are rare in Sage 300.
Why quarantine a truncated code instead of just posting the shortened version?
Truncation produces a syntactically valid Sage 300 string that points at the wrong account, so committing it moves real money with no error to flag the mistake. Routing TRUNCATED to quarantine preserves the original Procore code and the truncation detail for a controller, who can either remap the code or restructure the upstream Work Breakdown Structure so the segment fits. Auto-correction is reserved for lossless, reversible steps such as delimiter normalization and zero-padding.
How do I find the real Sage 300 segment widths for my install?
Segment structure is per company database, so query the GLACCOUNT table (or ask your Sage administrator) to read the configured segment count and the character width of each segment, then load those values into SAGE_SEGMENT_WIDTHS at startup rather than hardcoding them. Treat the widths as configuration, not a constant, so the same code runs unchanged against multiple Sage 300 companies.
How does this prevent duplicate ledger postings on retry?
CostCodeMapping is a frozen Pydantic model and translate_cost_code is a pure function, so an identical Procore input always yields a byte-identical sage_code and model_dump_json record. That determinism lets the posting job use the mapped code as an idempotency key — a retried message resolves to the same account and amount, so the ERP can dedupe instead of double-booking the commitment.
Related
- Budget Code Standardization — the standardization workflow this translation feeds
- How to map CSI MasterFormat to custom WBS codes in Python — the schedule-side counterpart to this cost-side mapping
- WBS Mapping Strategies — aligning schedule identifiers with the same taxonomy
- RFI Schema Design — the contract pattern reused for cost impacts tied to field inquiries
- Designing fallback routing for disconnected field devices — where quarantined mappings are escalated
← Back to Budget Code Standardization