# Hotel Contract Extractor

*No two hotel rate contracts share a layout, and one missed surcharge date changes the price a customer is quoted.*

**Category:** AI SYSTEMS / OPERATIONS
**Status:** live
**Period:** July 2025 – June 2026
**Built by:** HM Ammar, Digital Architect & Head of IT, Kuala Lumpur

**10 → 1** — ten data-entry staff, then one reviewer (self-reported)

**Stack:** TypeScript, React, Node.js / Express, PostgreSQL / Drizzle, GPT-4o + GPT-4o-mini, Tesseract OCR, ExcelJS, PM2 / nginx

## The challenge

Hotel rate contracts arrive as PDFs with no shared layout. Some are typed tables, some are scans. Each one carries seasonal rates, occupancy rules, child age brackets, extra-bed policies, meal plan supplements, taxes and blackout dates, and a team of ten retyped all of it into Samo Tour by hand. One missed surcharge date changes the price a customer is quoted.

## What I built

I built a full-stack TypeScript application around one rule: extract, then proofread. A PDF goes through a text-extraction cascade and then a staged extraction that routes by cost, with cheap gpt-4o-mini passes to identify the property and list the room types, and full gpt-4o for the passes that read money. The result lands in a tabbed review dashboard I built covering Rooms, Taxes, Meal Plans and, when the contract has them, Promotions, where a person edits and approves each section and can chat with the document to resolve edge cases. Only then does export run: a pricing engine expands every room into its priced permutations and writes an Excel workbook in Samo Tour's schema, or Uni-Hotel JSON.

## The detail

### Staged extraction

I never hand the contract to the model whole. One pass identifies the property, one pulls the top-level terms, one lists the room types, then each room is extracted on its own, so a five-room contract costs nine separate calls. Most stages retry twice with backoff and I validate each one independently; one unparseable room does not cost the whole contract.

### Graded OCR

I grade raw pdf-parse text before trusting it: at least 300 characters, an alphanumeric ratio above 0.35, contract vocabulary present. Fail that and pages render at 300 DPI for GPT-4o Vision, with Tesseract behind it both per page and for the whole document. A signal score weights table markers such as currency codes, rate, occupancy and season, and keeps whichever pass produced the more table-like text.

### Splitting the year

Contracts state one long validity range, then list peak dates, surcharges and blackouts against it. My date splitter cuts that range so every exception becomes its own priced period and the normal ranges fill the gaps: a year with two single-day surcharges becomes five periods. I key global date periods on range and check-in days together, so two periods over the same dates that differ only by weekday survive as separate rows.

### Every price enumerated

The pricing generator expands each room into every chargeable combination: adults, children by age bracket, sharing an existing bed or taking an extra one, extra-bed type, meal plan, date period. It writes one worksheet per meal plan in the schema the downstream system reads. A second generator applies promotions on top, honouring per-promotion flags for whether a discount reaches extra beds and meals.

### Approve, then reuse

Nothing exports until every section is approved, and the dashboard is not the only thing enforcing it. Both export routes re-check server-side and return 400 if a section is outstanding. Once approved, a contract can be saved as a template, shared into another user's library, cloned, or used to seed policies into a fresh session; over the last eight months I added that templates system, a second export format in Uni-Hotel JSON, and a separate pipeline for promotions.

## The outcome

The workflow changed shape: extract, then proofread. Processing became 10× faster and the team went from ten manual-entry specialists to one project manager reviewing output. Those are my own figures from running the operation, not something the code measures. The deployment is still up and serving, with the last commit in June 2026; the source no longer boots on my own machine, because the hosted Postgres I had pointed it at has since been shut down.

---

[All work](https://hmammar.world/) · [HTML version](https://hmammar.world/work/hotel-contracts.html)
