A repair shop can't print a price. Model the floor and the scope instead.
The last time you had your brakes done, the number at the top of the invoice was not the number you had been told. It was higher, and it was higher for a reason that was entirely legitimate: the rotors measured under spe
The last time you had your brakes done, the number at the top of the invoice was not the number you had been told. It was higher, and it was higher for a reason that was entirely legitimate: the rotors measured under spec, or a bolt was seized, or the pads that came off were not the pads the price list assumed.
Every shop owner knows this story. It is why so many independent garages have no price list on the wall at all โ a laminated sheet of numbers is, in their experience, a promise that a customer will later hold them to on a job whose scope nobody could have known in advance.
But "we don't publish prices" is not a neutral choice. It moves the entire decision to the telephone, where the shop is competing against three other shops on a number that means nothing without a scope. The shops that win that call are not the cheapest ones. They are the ones whose number arrives with a sentence attached.
So the interesting problem for anyone building software for these businesses is not how to publish a quote. It is how to publish a floor with a scope, in a structure that survives the car in front of you. That turns out to be a data-modelling problem, a typography problem, and a QR-code geometry problem in that order. This is how I ended up modelling it, with the measurements.
The two-field structure every service price needs
A coffee shop sells a flat white: same object, same price, one row. A brake job is a function of the vehicle, the condition and the parts tier โ three variables that are unknown when the price is printed and known when the work is done. A price record that cannot express that gap will be wrong on some cars, and a wrong price list is worse than none.
The smallest structure that survives contact with reality is a floor plus two arrays:
const job = {
name: "Brake pads, front or rear axle",
from: 189, // the floor, in the shop's currency
unit: "per axle", // the thing everyone forgets to say
includes: [ // what the floor buys
"inspection",
"pad replacement, standard pad",
"brake cleaner",
"road test"
],
excludes: [ // what moves the number, in customer words
"rotors, if measured under spec",
"callipers",
"brake fluid service",
"seized or broken hardware"
],
quotedBeforeWork: true // the promise that makes the floor honest
};
from without unit is the single most common cause of a bad price page. Half of all brake quotes in the world are per axle and the other half are implied to be per car, and the customer hears whichever one makes the number smaller. unit costs one field and removes the entire category of argument.
excludes is the field that makes the floor defensible. It is not legal boilerplate; it is the list of things that will actually happen on some fraction of the cars in the bay. Written in the customer's words โ "rotors, if measured under spec" rather than "additional parts and labour may apply" โ it converts a future surprise into a policy the customer read before they agreed.
The invoice lines nobody models
Go and read the bottom of a real repair invoice. Under the four lines the software knows about, there are five more, in 8-point type, that every shop charges and almost no shop publishes:
- shop supplies, usually a percentage of labour
- old-tyre disposal, per tyre
- a TPMS service kit, per wheel, on any car with sensors
- the seized or broken fastener that became an hour of labour
- a diagnostic fee, often credited against the repair if the customer goes ahead
None of these is dishonest. All of them are avoidable arguments. The fix is not to hide them better โ it is to render them next to the prices, as a policy block:
ON EVERY TICKET
Shop supplies ยท up to 3% of labour
Old tyre disposal ยท per tyre, where a tyre is replaced
TPMS service kit ยท per wheel, where the car has sensors
Frozen or broken hardware ยท quoted before we continue
Diagnostic fee ยท credited against the repair if you go ahead
All prices exclude tax
Six rows, and the invoice becomes a rendering of a policy the customer has already read. If you build for any trade with variable scope โ plumbing, dentistry, auto body, veterinary โ this block is the highest-value UI on the page and it is usually the one that does not exist.
Render the table, never the picture
The temptation, because it looks better in Figma, is to publish the price board as an image. Don't, for four reasons that are all boring and all fatal:
- A price inside a JPEG is invisible to a screen reader, and a shop's page becomes unusable for exactly the customers who rely on one.
- It cannot be found by the page's own search, or by a crawler, or by a voice assistant reading the shop's hours and prices out loud.
- It cannot be edited by the person who owns the prices. It has to go back to whoever has the design file โ which means it will be edited once a year instead of once a month.
- It does not reflow. A four-column table at 390 px is a stack of labelled rows; a screenshot of the same table is a horizontal scroll bar and a pinch gesture.
The one table on the page, in semantic HTML, with a <caption> and scope attributes, is also the thing that gets printed. Same record, two renderings โ which is the whole argument for keeping prices in data rather than in a document.
The QR code: the paper decides the distance
Now the part that actually got measured, because it is the part everyone guesses.
A shop price board is read from three distances at once: the customer at the counter (0.4โ0.6 m), the one at the door (1.2โ1.5 m), and the one sitting in the waiting area (2.5โ3 m). The planning rule โ and it is a rule of thumb from industry practice, not part of ISO/IEC 18004 โ is one unit of code per ten units of distance. So the required code width in millimetres is roughly the distance in centimetres:
const requiredCodeMm = (metres) => Math.round(metres * 100); // the 1:10 rule
requiredCodeMm(0.42); // 42 โ a card in the hand, in a counter stand
requiredCodeMm(1.26); // 126 โ a poster at the counter or the door
requiredCodeMm(3.0); // 300 โ a sign on the far wall of the waiting room
Those numbers are not arbitrary: 42 mm and 126 mm are exactly the code sizes the print files I maintain use, and the third one is the number that breaks the design.
Here is why. I measured the generated PDFs rather than trusting the spec sheets, and the stated code width includes the four-module quiet zone that Denso Wave's FAQ and GS1's 2D barcode guideline both require:
| Piece | Printed size | Code stated | Printed black square | Module |
|---|---|---|---|---|
| A4 poster | 210 ร 297 mm | 126 mm | 101.44 mm | 3.07 mm |
| Four-up counter card | 93.0 ร 126.5 mm | 42 mm | 33.83 mm | 1.02 mm |
| Price list footer code | A4, held | 20 mm | 16.12 mm | 0.49 mm |
The black square is smaller than the stated width because 33 modules of payload plus four modules of margin on each side is 41 squares across: 126 / 41 ร 33 = 101.4 mm. That ratio is the whole arithmetic of QR print sizing, and it is why "make the code 20% bigger" is not a thing you can do without re-checking the paper.
The 3-metre wall that cannot be solved with an A4
A code read from 3 m wants about 300 mm of width. An A4 sheet is 210 mm wide. The requirement is geometrically impossible on the paper the shop already has. The options are:
- an A3 (420 mm wide) with a 250โ300 mm code, which leaves 60โ85 mm of paper on each side โ room for a name and one line, nothing else;
- or, much better, a second piece at the counter where the customer is already 0.4 m away and 42 mm is generous.
This is the sort of thing that only shows up when you do the arithmetic instead of buying a bigger poster. Most "our QR code doesn't scan" reports are not a scanning problem; they are a distance problem wearing a scanning problem's clothes.
Payload length, measured
The other half of the geometry is that the code does not grow when you put more in it โ it gets denser, and every module gets smaller on the same piece of paper. Measured with the same QR call the print kit makes, at error correction level M:
| Payload | Bytes | Modules | With margin | Module at 42 mm |
|---|---|---|---|---|
/s/mikes-auto |
44 | 33 | 41 | 1.02 mm |
mikesautoservice.com/price-list |
39 | 29 | 37 | 1.14 mm |
| short URL + 40-char campaign string | 85 | 41 | 49 | 0.86 mm |
| long slug + parameters | 117 | 45 | 53 | 0.79 mm |
Forty characters of utm_ parameters cost about a sixth of every module on the card โ on the one piece the customer handles under a fluorescent light, to tell you which sign they scanned. If you want that attribution, print two pieces with two short addresses. Don't spend the modules.
Print at 100%, or the geometry is a lie
Every measurement above assumes the file is printed at actual size. "Fit to page" is how A4 artwork lands on US Letter โ 210 ร 297 mm against 215.9 ร 279.4 mm โ and the shorter height forces a 94% scale. A 42 mm code prints at 39 mm; the cut marks on a four-up card sheet stop matching the cards.
Two smaller rules worth building into the print file itself:
- Matte, not gloss. A gloss laminate under a counter spotlight throws a highlight exactly where the phone is pointing. It is the most common reason a perfectly printed sign fails at 9 a.m. and works at 2 p.m.
- Nothing inside the quiet zone. The white border is part of the code. A border line, a photograph or a price sticker printed into that margin will kill the scan while looking like a design choice.
The checklist I use before a print run
- Print one of each piece at 100% on matte paper โ not a batch.
- Scan both with two different phones, at the counter, at the hour the shop opens.
- Measure the printed black square with a ruler: 101 mm on the poster, 34 mm on the card. If it is off by more than a millimetre, the printer scaled the file.
- Read the code out loud from the customer's position: if it needs a second of waving, it is too small for that distance, and the answer is a different piece, not a bigger print run.
- Print one piece of the board and put it where the queue forms. Watch whether the questions change before printing the rest.
What this looks like shipped
The board I ended up building for a repair shop has four lines โ oil and filter, brake pads per axle, rotors quoted separately, tyre fitting โ each with an includes line underneath, plus the six-row "on every ticket" block, hours with an open-now state, a tap-to-call button, directions, drop-off-or-wait, and reviews. One mobile page, seven blocks, no login, no app.
The long version of the reasoning, with every published reference price (RepairPal's national estimator, Valvoline's own oil-change ranges, Walmart's and Pep Boys' published tyre installation figures) and every millimetre measured in the generated PDFs, is on the site: A repair shop's price board: oil, brakes and tyres.
If you are pricing the build-vs-subscribe question for a shop like this, there is a three-year cost calculator that puts a builder subscription next to a one-off page and shows the cases where the subscription is the right buy.
And if what you actually need is the printed half โ the poster, the counter cards, the printable price list, the review cards and the runbook โ the eight-file kit is $49 once, built in 24 hours, no subscription.
The generalisable lesson, if you build for trades rather than for restaurants: model the floor and the scope separately, render the table instead of the picture, and let the paper decide the QR size. The rest is typography.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.