DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

Extracting Structured Fields From a Car Rental Agreement

A rental agreement is a form with about forty fields, of which three decide almost every subsequent dispute: what happens about fuel, how far the car may be driven, and which damage waivers the renter accepted. All three are printed in prose that varies between every brand and every country.

Fuel policy is four behaviours in twenty wordings

There are only a handful of things a rental company actually does about fuel, and an enormous number of ways of saying them. The behaviours are: return it as full as you got it and pay nothing; buy a tank in advance at a stated price and return it empty with no refund for what is left; return it at any level and be charged a per-unit refuelling rate that is well above pump price; or return it at whatever level it was collected at, which may not be full.

The wordings include “Full to Full”, “Same to Same”, “Return with same fuel level as at pickup”, “FPO”, “Fuel Purchase Option”, “Pre-purchase”, “Fuel Service Charge”, “Refuelling Service”, and a checkbox with no words at all next to a printed price per gallon or litre. Stored as free text, this field is unusable: a query for “how many of our rentals used prepaid fuel” has to match against a phrase list that grows every time a new brand appears.

So enumerate it, and keep the raw text beside the enum:

"fuel_policy": {
  "code": "prepaid_full_tank",     // full_to_full | prepaid_full_tank
                                   // refuel_on_return | same_as_received
                                   // ev_state_of_charge | unknown
  "raw": "FUEL PURCHASE OPTION - ACCEPTED",
  "prepaid_price_per_unit": 4.29,
  "unit": "gal",
  "refund_for_unused": false
}
Enter fullscreen mode Exit fullscreen mode

Electric vehicles need their own member rather than being squeezed into the fuel enum, because the quantity is a state of charge in percent rather than a volume, and the return condition is commonly expressed as a minimum percentage with a flat penalty below it. A field typed as litres cannot hold “return above 70%”.

The unit is not optional and not inferable from the brand. The same company’s form prints gallons in one country and litres in another, and the numbers are similar enough — a per-unit price of 4.29 is plausible in both — that a missing unit produces a wrong answer rather than an obvious one.

A mileage cap without a period is not a cap

Mileage terms come in two shapes. Unlimited, which is a boolean. Or capped, which is a quantity, a unit, a period and an overage rate — and the period is the part that gets dropped. “150 miles” on a seven-day rental means something four hundred percent different depending on whether it is per day or per rental.

Model it explicitly and refuse to let the period default:

"mileage": {
  "unlimited": false,
  "allowance": 150,
  "unit": "mi",              // mi | km
  "period": "per_day",       // per_day | per_rental | per_week
  "overage_rate": 0.25,
  "overage_unit": "mi"
}
Enter fullscreen mode Exit fullscreen mode

The unit deserves the same suspicion as the fuel unit, and for a sharper reason: an odometer reading is recorded at pickup and return on the same form, and the distance driven is their difference. If the car reports kilometres and the agreement’s allowance is stated in miles, or the two odometer readings are transcribed from different display modes, the computed distance is out by a factor of 1.609 — which on a capped rental is the difference between no overage charge and a substantial one. Extract the odometer unit from the form where it is stated and flag it as unknown where it is not.

Note also that the odometer readings themselves are the one place on this form where a check is available: return reading minus pickup reading must be non-negative, and a negative difference means a digit was misread rather than that the car travelled backwards. It is a weak check but it costs nothing and it catches the transposition class of error.

An empty box is not a decline

The optional coverages — collision or loss damage waiver, supplemental liability, personal accident and effects, roadside assistance — are presented as a block of accept/decline choices, each requiring the renter’s initials. This is the field that decides who pays for a dented door, and it is the field most commonly modelled as a boolean.

A boolean cannot express the state that actually causes the dispute. There are three:

  • Accepted. The accept box carries a mark and the initials line carries initials. The coverage is on and it appears as a per-day charge in the rate block, which gives you a second, independent confirmation.
  • Declined. The decline box carries a mark and initials. The renter affirmatively refused, which is the state the company needs evidenced.
  • Not evidenced. Neither box is marked, or a box is marked but the initials line is blank, or the block is illegible in the scan — which is a case of its own, not a value. This is not a decline. It is a document that does not say, and treating it as a decline is the extraction error that turns into a legal argument.

The corroboration is worth building in. If the waiver block reads as accepted, a matching per-day line should appear in the charges; if it reads as declined, that line should be absent. Where the two disagree, the record is inconsistent and belongs in review — and, usefully, the charge line is printed text while the initials are handwritten, so the more reliable of the two signals is checking the less reliable one.

Initials presence is a genuinely hard visual judgement at typical scan resolution, so this is a field to route to a human by rule rather than by confidence threshold. The lien release field on a vehicle title has exactly the same shape and exactly the same failure, and the general handling belongs with the confidence and review machinery rather than in this page.

Why the estimated total will not reconcile

The daily rate times the number of days does not equal the estimated total, and unlike the hotel folio case the reason is not a varying rate. It is a stack of named recovery fees, most of which are per-day and some of which are percentages of the base, and which differ between locations at the same brand: a concession recovery fee at airport locations, a vehicle licensing or registration recovery fee, a customer facility charge, a tyre and battery fee, an energy surcharge, plus state and local taxes on some or all of the above.

Extract them as a list of labelled line items with their basis — per day, per rental, or a percentage — rather than summing them, and check only the identity that has to hold: base plus optional coverages plus fees plus taxes equals the stated estimated total. That is the same reconciliation identity used throughout this cluster, and it is the only check on this document that does not require you to know the location’s fee schedule.

Remember that the figure on the agreement is an estimate. The final invoice arrives later and differs by fuel, overage, tolls, extensions and damage. If you are matching agreements to invoices, the agreement number or reservation number is the join key and the totals are not expected to match — a pipeline that treats a difference as an error will flag nearly every rental.

The record

Everything above assembles into a record whose shape is dictated by the disputes it has to answer rather than by the layout of the form:

{
  "agreement_number": "RA-4471902",
  "pickup":  { "location": "SEA Airport", "datetime_local": "2026-05-04T09:15",
               "time_zone": "America/Los_Angeles", "odometer": 21440 },
  "return":  { "datetime_local": "2026-05-09T08:00",
               "time_zone": "America/Los_Angeles", "odometer": 22187 },
  "odometer_unit": "mi",
  "distance_driven": 747,
  "mileage": { "unlimited": false, "allowance": 150, "unit": "mi",
               "period": "per_day", "overage_rate": 0.25 },
  "fuel_policy": { "code": "full_to_full", "raw": "FULL TO FULL" },
  "coverages": [
    { "code": "LDW", "state": "declined", "initials_observed": true,
      "charge_line_present": false },
    { "code": "SLI", "state": "not_evidenced", "initials_observed": false,
      "charge_line_present": false }
  ],
  "stated_estimated_total": 612.40,
  "foots": true
}
Enter fullscreen mode Exit fullscreen mode

The pickup and return times carry an explicit zone for the reason set out on the reservation confirmation page: a rental that crosses a state line into another zone is returned at a wall time in a different zone from the one it was collected in, and normalising both to UTC with one assumed offset silently changes the rental duration by an hour.

Related

Top comments (0)