Retyping is where money escapes
An hour that never got transcribed never got billed, and nobody finds out — because there is nothing to compare it against.
// CASE STUDY — CLIENT SYSTEM
> Labor tracking and billing for industrial field work
Built for Engemil, an industrial maintenance and assembly contractor in Mogi Guaçu, Brazil — and shaped from the start to generalize to a sector that all runs the same cycle. It replaces paper → spreadsheet → retyping with one flow: import the spreadsheet engineering already fills in, keep every work package's balance as a live account, and export the monthly billing document byte-identical to the format the client signs.
// 01 — THE PROBLEM
Engemil allocates crews inside its largest client's plant, and every month it has to prove — job by job, person by person, day by day — how many hours were worked and what they cost. That document is signed by both sides. Before the system, producing it looked like this:
The old cycle
An hour that never got transcribed never got billed, and nobody finds out — because there is nothing to compare it against.
The client approves work packages with budgeted hours. Knowing how much was left in a package meant adding up entries by hand.
The billing spreadsheet has an exact shape — font, colour, number format, block position. A billing document that merely looks similar comes back.
// 02 — HOW THE RULES GOT HERE
The system was drawn out in successive rounds of discussion, review and feedback with the person at Engemil who was responsible for producing all of this by hand — each round trading an assumption for a fact about the operation. The real artefacts served as the specification: the reference billing workbook, the engineering tracking spreadsheet, the master package sheet and the job register. Three rules that only ever surface in conversation with someone who has done the work:
The system briefly had Project#kind (ase | hh | pacote). It was wrong: the same job has package days, hourly days and extra-service days. What declares the nature of the work is the daily report, not the job record. The column was removed.
FE and FO are codes, not time. And the night-shift differential is a 45% uplift on the rate, not time worked — adding it to the hours inflated the billing.
They carry the word “UNIFIED” where the issue date should be. The field became optional because of that, not out of modelling elegance.
// 03 — THE DOMAIN
Ten domain tables. The whole operation fits in one model, which is why it is one application and not several.
User (admin | supervisor | foreman)
└─ ProjectSupervisor ── Project ── Package (the budgeted slice)
└────── Rdo (the day of work)
└── WorkEntry (the line: who, role, hours)
Employee ── Position (role, hourly rate)
ImportBatch ── ImportError| The five hour types | factor | — |
|---|---|---|
| hn | 1.0 | regular hour |
| fa | 1.6 | overtime, factor A |
| fb | 2.0 | overtime, factor B |
| an | 0.45 | night differential — an uplift, not time |
| fd | 1.0 | compensatory rest |
Used hours, remaining hours, progress and value-to-bill all come out of a query at read time. A 663-hour package that receives a 9-hour report shows 654 hours remaining immediately, with no column being written — and therefore none of the classic risk of a stored total drifting from the lines that make it up.
// 04 — ARCHITECTURE
It is a Rails monolith, and that is a choice rather than a limitation. The data is tightly related — an hour entry only exists inside a daily report, which only exists inside a job — and there are no two teams moving at different speeds. Splitting it into services would buy distributed transactions for a company that closes one billing cycle a month.
The rules that always hold — validation, scoping, balance calculation.
Who can see and do what, per record. The same policy scope that filters the list filters the autocomplete and the dashboard — a counter that sums hours the viewer may not read is a leak wearing the face of a metric.
Measurement, ProjectWorkReport, DashboardReport, RdoQuery — reads that do not deserve to be models.
One importer per input file format; exporters for the billing workbook, the covering letter and the printable daily report.
Admin does everything and is the only one who grants access or sees package money. Supervisor gets the whole operation minus user creation. The foreman is in the field: signs the report, sees only where they are assigned.
The daily report is closed inside the request that saves it, and imports are pushed by the page itself, a chunk per submit. A worker polling the database would keep the container from sleeping, so the app would stay up around the clock waiting for one import a month.
Built on
// 05 — DECISIONS WORTH TELLING
Four problems where the obvious implementation is the wrong one.
“Identical” does not mean “similar”. The work was unzipping the reference .xlsx and reading the XML, because the eye does not catch what breaks: fills use theme colours with tint rather than RGB, so reading only the RGB attribute sees no fill at all; money uses the ACCOUNTING format, which is what pins the currency symbol to the left edge and the number to the right; and in the summary sheet each hour type occupies two columns, so writing into consecutive columns puts every value two cells to the left. There is a test that strictly parses the generated styles.xml, because an unescaped quote in a format code produces a file Excel refuses to open — and neither the reading library nor the content tests notice.
The idempotency key is a SHA-256 of the row: job, foreman, role, date and hour type. An upsert updates the same record instead of creating a second one. Without the hour type in the digest, a regular and an overtime entry on the same day collided and one vanished. A detail that cost real time: the digest carries the package id only when there is one — adding it unconditionally would change the digest of everything already stored, and the next upload of the same file would create a copy of every line.
Missing jobs, roles, employees and users are created, and the screen says which ones — creating silently is what turns a misspelled role into a permanent record nobody can trace. A missing package, though, rejects the line: packages carry estimated hours and a sale value, so inventing one would write a budget nobody approved.
The foreman is on site with a phone. A seven-column table behind horizontal scroll technically fits, and nobody drags to the status column without a header in view to know where they are. Below 640px every table becomes a labelled card, with the label coming from each cell's data attribute. A test fails any new table that skips it, because such a table would pass every request spec and still disappear on the phone.
// 06 — NUMBERS
Repository counts as of 30 Aug 2026, and figures from real use.
From real use
// 07 — WHERE IT STANDS
A portfolio that only lists what worked does not survive the next question. Scale tests count queries: a listing has to cost the same with ten records and with ten thousand — a real import file reaches 8,504 daily reports, which is why batch closing is one SQL statement per slice rather than a walk record by record.
It was built for one contractor, but the cycle it replaces is not specific to one: crews allocated inside a client's plant, hours proven month by month, a billing document in a format the client dictates. Generalizing it into a product for that sector is the direction — the multi-client work has not started, and nothing here is sold to a second company yet.