A carrier settlement becomes ledger lines like this: each load's linehaul and fuel surcharge is revenue, each deduction gets its own expense account, escrow and Form 2290 savings go to an asset account because they are still the driver's money, and the net is the cash that hit the bank. Before any of it posts, the code adds the loads, subtracts the deductions and compares the result with the net printed on the settlement. If it is off by a cent, nothing posts.
This post is the data model and the checks, in Python. It comes from building this for a bookkeeper whose clients are all leased-on owner-operators, about 80 drivers paid weekly.
What a settlement looks like
Every carrier prints it differently, but the content is the same. Once extracted, a week for one driver looks something like this (fictional numbers):
settlement = { "carrier": "Example Freight Lines", "driver": "R. Alvarez", "truck": "4471", "pay_period_end": "2026-10-24", "pay_date": "2026-10-30", # a Friday "loads": [ {"ref": "L-88120", "linehaul": "2140.00", "fuel_surcharge": "386.50"}, {"ref": "L-88191", "linehaul": "1875.00", "fuel_surcharge": "301.20"}, ], "deductions": [ {"type": "FUEL ADVANCE", "amount": "1412.66"}, {"type": "OCC/ACC INSURANCE", "amount": "41.00"}, {"type": "PHYS DAMAGE INS", "amount": "118.00"}, {"type": "ESCROW", "amount": "100.00"}, {"type": "2290 SAVINGS", "amount": "15.00"}, {"type": "ELD", "amount": "22.00"}, {"type": "TOLLS", "amount": "64.35"}, ], "printed_net": "2929.69", } Step 1: map deduction types to accounts
The bookkeeper already has a chart of accounts. The mapping is his, not the software's. Two types are never left to a guess: escrow and 2290 savings are assets.
from decimal import Decimal ACCOUNT_MAP = { "FUEL ADVANCE": ("Fuel", "expense"), "OCC/ACC INSURANCE": ("Insurance", "expense"), "PHYS DAMAGE INS": ("Insurance", "expense"), "ESCROW": ("Escrow held by carrier", "asset"), "2290 SAVINGS": ("Form 2290 savings", "asset"), "ELD": ("ELD and phone", "expense"), "TOLLS": ("Tolls", "expense"), } LOCKED_TYPES = {"ESCROW", "2290 SAVINGS"} # never auto-mapped by a model When a type shows up that isn't in the map (a new "LUMPER" line, a "PLATE PRORATE"), the settlement waits. You can let a model suggest an account, but a person confirms it once, and that answer goes into the map for every driver after that.
Step 2: prove it before posting
D = Decimal class NeedsReview(Exception): pass def prove(s): gross = sum(D(l["linehaul"]) + D(l["fuel_surcharge"]) for l in s["loads"]) taken = sum(D(d["amount"]) for d in s["deductions"]) computed_net = gross - taken printed = D(s["printed_net"]) if computed_net != printed: raise NeedsReview( f"lines add to {computed_net}, settlement prints {printed}, " f"difference {printed - computed_net}" ) unknown = [d["type"] for d in s["deductions"] if d["type"] not in ACCOUNT_MAP] if unknown: raise NeedsReview(f"new deduction types: {unknown}") return gross, computed_net For the example: gross is 4,702.70, deductions are 1,773.01, net is 2,929.69. It ties, so it can post.
This is the most important part. Carriers do sometimes print a total that their own lines don't add up to. In testing we saw a settlement whose printed total was $10 more than its lines. Posting the printed total would put $10 of revenue in the books that no load supports. Posting the lines would make the bank deposit look short. Neither is right without a human looking, so the code stops.
Step 3: build the journal entry
def to_journal(s): gross, net = prove(s) lines = [] for load in s["loads"]: lines.append(("Freight revenue: linehaul", None, D(load["linehaul"]))) lines.append(("Freight revenue: fuel surcharge", None, D(load["fuel_surcharge"]))) for d in s["deductions"]: account, _kind = ACCOUNT_MAP[d["type"]] lines.append((account, D(d["amount"]), None)) if net >= 0: lines.append(("Bank: business checking", net, None)) else: lines.append(("Payable to carrier", None, -net)) debits = sum(l[1] or 0 for l in lines) credits = sum(l[2] or 0 for l in lines) assert debits == credits, "entry does not balance" return {"date": s["pay_date"], "memo": f'{s["carrier"]} settlement, truck {s["truck"]}', "lines": lines} A few decisions are baked in here, and each one came from a real week.
The date is the pay date. Not the pay period end and not the upload date. The bookkeeper's drivers are paid on Fridays, and the bank deposit lands that day, so matching the deposit later is trivial.
A negative week goes to a payable. When deductions are bigger than the loads (a short week with a full insurance charge), the carrier collects it from a later settlement. Booking it as a weird negative bank line or an expense hides what actually happened: the driver owes the carrier money.
Escrow is an asset, every week. The $100 isn't a cost. It is the driver's money sitting with the carrier, and the balance in the books should match what the carrier says it holds.
Booking gross, not net. The tempting shortcut is one line: "deposit 2,929.69, revenue". That makes the P&L wrong in two directions. Revenue is understated by 1,773.01 and every real cost (fuel, insurance, tolls) disappears. It also makes escrow invisible, which matters the day the driver leaves the carrier and wants it back.
Step 4: don't post the same week twice
Bookkeepers re-download files. Carriers re-send them. Hash what makes a settlement unique and refuse duplicates:
import hashlib, json def fingerprint(s): key = {k: s[k] for k in ("carrier", "driver", "truck", "pay_period_end", "printed_net")} return hashlib.sha256(json.dumps(key, sort_keys=True).encode()).hexdigest() Also refuse a settlement whose pay period hasn't ended yet. It happens more than you would think, usually from a preview PDF.
Matching the driver
Names are not unique, and two drivers with the same name at one carrier is a case the setup has to handle. Match on truck number first, name second, and if neither matches exactly, stop and ask. A settlement posted to the wrong driver is worse than one that waits a day.
How it held up
Before go-live we ran 648 settlement statements across 9 weeks against an answer key built from the bookkeeper's own entries. Every one that posted matched to the cent. The rest stopped for a decision, which is the behavior you want: a short list of "look at these" instead of a silent wrong number.
If you want the result without writing it
Disclosure: I work on ZeraBooks. The page on trucking bookkeeping automation describes the full setup we built (drivers board, P&L by driver, the stop rules above), and we build it free for trucking bookkeepers in their carriers' layouts. If you are the driver rather than the bookkeeper, the owner-operator bookkeeping guide covers the same gross-versus-net rule in plain English, with a weekly routine.

