Try to classify a batch of posted AR charges by charge type in Yardi Voyager and you'll hit a wall that looks like a bug but isn't: TRANS — the table every posted charge lands in — has no charge-code column. Neither does DETAIL, the line-item table underneath it, in the rows where you'd expect to find one. Execution-verified against a live Voyager schema: on charge-application line items, the charge-code column exists but is never populated.
That's not a data-quality problem. It's a sign you're looking in the wrong table.
The header doesn't know what it billed
A posted AR charge is a TRANS row with iType = 7 — we've mapped the full iType code set separately, and 7 is the AR-charge code. Once you're at that row, the natural next question is "what kind of charge is this — base rent, a CAM recovery, a late fee?" TRANS doesn't say. Neither does DETAIL's application-line records, which is where a lot of first attempts go looking, because DETAIL's columns are document-class-specific: the same column pair means different things depending on which two document types are being applied against each other on that row, and on the application-line rows tying a charge to its receipt, the charge-code slot simply isn't used.
So the charge type has to be resolved somewhere upstream of the posted transaction, not off it directly.
Where the charge type actually lives: CAMCHARG
The answer is a table called CAMCHARG — and its name undersells what it does. It's not scoped to CAM/recovery billing specifically; it's the record of every module-generated recurring or recovery charge line, holding the fields that resolve exactly the question TRANS can't answer:
HCAMRULE→ the configured charge rule that generated the lineHCHARGECODE→CHARGTYP, the actual charge type (base rent, a specific recovery category, a fee code — whatever the client's chart of charge types defines)HTENANTand a unit reference — who's being billed and for whatDTMMYY— the month bucket the charge belongs toDCHARGE— the generated amountHPOSTREF— the pointer forward intoTRANS, once the charge posts
That last field is the pipeline itself: CAMRULE (the configuration) generates CAMCHARG (the calculated line) which posts to TRANS with iType = 7, linked back via CAMCHARG.HPOSTREF = TRANS.HMY. To classify a billed charge by type, you join through CAMCHARG — never through TRANS or DETAIL directly, because the charge code was never carried that far downstream.
A minimal version of that join:
SELECT
t.HMY AS trans_id,
ct.SNAME AS charge_type_name,
cc.DCHARGE AS billed_amount,
cc.DTMMYY AS billing_month
FROM TRANS t
JOIN CAMCHARG cc ON cc.HPOSTREF = t.HMY
JOIN CHARGTYP ct ON cc.HCHARGECODE = ct.HMY
WHERE t.ITYPE = 7
Notice what's absent: no join through DETAIL at all. That's deliberate — it's the exact join a first attempt usually reaches for, and it's the one that returns nothing.
The caveat that matters: manual charges skip this pipeline entirely
CAMCHARG covers module-generated billing — charges the recurring/recovery engine calculated from a configured rule. Not every AR charge comes from that engine. Manual, one-off charges — think ad hoc penalties, true-ups entered by hand, or corrections a property accountant keys in directly — bypass CAMCHARG completely. They still post to TRANS with iType = 7, but there's no CAMRULE/CAMCHARG trail behind them at all.
Those charges are identifiable a different way: they carry free-text context in TRANS's notes field, and they reference a batch/control header rather than a charge rule — Voyager encodes that header as its own iType (16) with a distinct "control batch" role, sitting behind the manual charge as its HPARENT2 link. If a charge-classification query only checks CAMCHARG and treats everything unmatched as an error, it will mislabel every legitimate manual charge as a data problem. The correct read is: unmatched to CAMCHARG means manual, not means broken.
That split matters for anyone building a charge-type report: total posted charges for a period will always be larger than what CAMCHARG alone can classify, and the gap is exactly the manual-charge population, not a bug in the join.
Why this shape exists
It's worth pausing on why Voyager separates configuration, generated line, and posted transaction into three tables instead of stamping the charge type straight onto TRANS. A CAMRULE is a standing instruction — bill this tenant this amount for this charge type, recurring, until told otherwise. CAMCHARG is what that instruction produces for one specific month, before it's posted. TRANS is the posted fact. Keeping them separate is what lets Voyager answer questions a single flat table couldn't: which configured rules haven't generated a charge yet this month, which generated charges haven't posted, and which posted charges never came from a rule at all. Collapsing that into one table would answer "what got billed" and lose the ability to answer "what should have been billed but wasn't."
Stop guessing at the billing pipeline
This is the kind of architecture that costs real time to reconstruct from scratch — the join everyone tries first (TRANS/DETAIL straight to a charge-type table) is the one that quietly returns nothing, and there's no error message pointing you toward CAMCHARG instead. PropETL's SQL Query Assistant carries this pipeline, the DETAIL document-class trap, and the rest of Voyager's charge and billing conventions as queryable, execution-verified knowledge inside Claude.
It's included in every tier. Start the free 7-day trial — no credit card — and ask it to trace the next charge type you can't find.
