02The story
What it is
A point-of-sale application for a small shop: a till, a catalogue, customers, invoices, and stock. One account is one shop, and every query is scoped to its owner.
I had written most of it earlier as a way to learn Laravel properly. Coming back to it to put it in this portfolio, I read it as if someone else had sent it to me — and found enough wrong that the honest thing to write up is the audit rather than the feature list.
The thing it was named for and did not have
The repository is called Inventory Management System. There was no inventory.
A sale wrote an invoice row and left the catalogue completely untouched. Nothing in the schema could answer "how many are left", so nothing could stop you selling the twentieth of three, and there was no page anywhere that would tell a shopkeeper to reorder.
Stock now exists in two pieces, because one is not enough. products.stock is
the balance. stock_movements is how the balance came to be — a signed row for
every change, with the reason and the resulting total:
- movement reasons
- 4
- row per stock change
- 1
- ways to edit the balance directly
- 0
The last of those is the decision I would defend hardest. Stock is deliberately not a field on the product form. It moves through restocking and through sales, both of which write a ledger row. Letting a form overwrite the balance would put the number and its history permanently out of step, and then the ledger is decoration.
Selling takes a row lock while it checks and decrements, so two tills reaching for the last unit at the same moment cannot both read "1 left" and both succeed. Deleting an invoice puts everything on it back and records that as a reversal rather than quietly editing the earlier rows.
Three holes, all the same mistake
The server believed the browser. invoiceCreate read total, vat and
payable straight out of the request body and wrote them to the invoice. The
browser was not merely displaying the price, it was setting it — a hand-written
POST could buy a desk lamp for a dollar.
The request now carries only what the browser is entitled to decide: which products, how many, which customer, what discount was agreed. Every figure is computed server-side from the catalogue. I found this one properly by accident, while testing: I fed the endpoint a line the page had priced at $1,450 and the server charged $765.45, because $1,450 was never a real price for that product.
Deleting an invoice needed only its number. The child rows were scoped to
the owner; the parent was not. Invoice::where('id', $inv_id)->delete(). Any
signed-in user could delete anybody's sale by guessing an integer. The ownership
check had been written — it just was not applied where it mattered, which is the
version of this bug that survives review.
The request chose which file to delete. Product delete and update read a
file_path field out of the POST body and handed it to File::delete(). The
path now comes from the product row, and the resolved path is confirmed to sit
inside public/uploads before anything is removed. Uploaded images get a
generated filename too — the original was pasting getClientOriginalName()
straight into the path.
The bug that only MySQL could see
password was VARCHAR(50). A bcrypt hash is sixty characters.
It had never failed, because the project was built against SQLite, which does not
enforce VARCHAR lengths. The column was wrong from the first migration and the
development database politely agreed with it. Moving to MySQL, every single
registration died on Data too long for column 'password'.
That is why the test suite runs on MySQL and not on an in-memory SQLite
database. A suite on SQLite would have agreed with the bug rather than caught it,
and the queries here are MySQL's anyway — DATE(), CAST(x AS UNSIGNED),
SELECT … FOR UPDATE.
- feature tests
- 27
- assertions
- 93
- failures
- 0
Smaller things, found by reading
Not everything was structural. These are the ones worth listing because each is a different way for code to look fine and not work:
- The sales report passed
FormDateas both ends of its range, so every PDF claimed to cover a single day whatever you asked for. - The reports page had its
<script>after@endsection. Blade discards anything a child template puts outside a section, so the Download button called a function that never reached the page at all. - Registration validated the form, showed a message for the empty field, and
then fell straight through and posted anyway — no
returnafter the warning. - The OTP and password-reset forms caught every error into an empty block. A wrong code left the loading bar running and said nothing.
ResetPass()calledevent.preventDefault()without declaringevent. That works in Chrome, which still exposeswindow.event, and does not in Firefox, where the form did a full page submit instead.
The interface
The application worked before this and looked like a template. I rebuilt the front end on a small token-based stylesheet — one accent, three stock colours, one radius, one shadow — over the Bootstrap that was already there.
The accent is indigo rather than the magenta it started with, chosen partly because it sits far enough from green, amber and red that a primary button and an "in stock" badge can never be confused. That matters more here than it sounds: colour is doing real work on this screen.
The dashboard was seven identical counters. It is now ordered by the question being asked — money taken today first, catalogue counts last — with a takings chart, a reorder list, best sellers and recent sales. The till became three columns in the order the sale actually happens. Every error message names the product and the number left, because "something went wrong" tells a cashier with a queue in front of them nothing they can act on.
What is honestly not done
It is not deployed, so it has no users and no outcome section. Payment is a total on a screen, not a card reader. There is one role — the shop owner — with no staff accounts or permissions, which is the next real piece of work and a much larger one than it looks. Sales tax is a single fixed rate.
What the project produced is the thing I wanted from it: reading my own Laravel code carefully enough to find three ways it could be abused, and being able to say exactly why each one was wrong.


