Dansday

The Month In-Stock Phones Looked Sold Out

Published on Nov 30, 2025

Twenty-four pull requests at 3cat in November, twenty-three merged, and a hundred and twenty-one commits on a Discord bot I build in my own time. My busiest month of the two years by a distance.

At work, three tickets were marked priority zero and all three were about money — not revenue, but what running the site costs. One of them caused customers to see phones we had in stock as sold out.

Three P0s, all about the bill

The database query optimisation took three attempts across two months. I got the dates wrong when I first wrote this. What actually happened: one pull request opened on 28 October at 699 additions and 624 deletions, a second opened on 4 November at 551 additions, the third opened and merged on 7 November at 378 additions across six files — and then the first two were both closed, unmerged, on 11 November, four days after their replacement had already shipped. I originally described the first as "closed in October" and the second as "closed on 4 November", which were their opening dates, not their closing ones. I also quoted the smaller of the two abandoned attempts and left the larger one out. Alongside it, a general cost-improvement ticket at 261 additions and 266 deletions over twenty-two files, and a Largest Contentful Paint fix that is twelve additions and sixty-seven deletions.

That last ratio is the month in miniature. Making the page render faster meant removing five times more code than it added.

The cost ticket's first line is the one I would frame:

1) Replace the static image in "Our Stores" + map in PDP "Our Stores"
   section with new static image

The interactive Google Map on the product page became a picture of a map. It is not a downgrade anybody will notice — the map was decoration next to a list of store addresses, and each render was a billable API call. A picture costs nothing to serve and answers the same question.

Why October removed the nearest store

Last month I wrote about removing the auto-selection of a customer's nearest store, said I did not know why because the ticket did not say, and noted that I had praised the feature in writing two months earlier.

The second line of this month's cost ticket answers it:

2) Investigate if 'find my neares...

It was the bill. The geolocation lookups, the Places API calls, the interactive maps — all of it was a metered Google service, and by November the exercise was working out which parts of it were earning their keep. The answer for auto-selecting a store was no.

So the full arc of that capability, which took me four articles to see: built in May on every page view and reverted, rebuilt in August on the checkout where I argued it belonged, removed in October, and this month I can finally say it was removed because somebody added up what it cost. Nobody was wrong at any step. The feature was simply worth less than the invoice.

Precomputing the product page

The database work that merged on 7 November did something reasonable: it stopped the product page asking the database questions inside a loop.

A configurable product renders a grid of options — colours across the top, storage sizes below — and each one needs to know whether a variant exists for it and whether that variant can actually be bought. The template had been working that out per option. The optimisation moved all of it into the controller, 249 additions, and handed the template a prepared array:

@if(isset($precomputedData['variantImages']))
$hasVariant = isset($precomputedData['optionHasVariant'][$key][$color]) && ...
$isCheckoutable = isset($precomputedData['optionCheckoutable'][$key][$color][...]) && ...

This is the correct shape of fix and it is the same instinct as May 2024's promotion query and July 2024's similar-products block: ask once, up front, for everything the page will need.

It also moves a correctness question out of the database and into a data structure. The database, asked whether a variant is in stock, will tell you the truth. An array has to be built correctly and then read correctly, and those are two separate opportunities.

In-stock phones looked sold out

Four days after that merged, I raised a hotfix titled Default PDP mark as OOS. Six days after that, another one:

The variant is actually in stock, but marked as OOS
(same as 1668)

Customers were looking at product pages where the colour they wanted appeared greyed out and unbuyable, on devices sitting in a shop in Kuala Lumpur. Twice in a week, both raised by me, both hotfixes.

The cause is in the line the optimisation had introduced. The prepared array has two different shapes depending on which attribute row you are in. For the first attribute — the colour swatches — checkoutability is stored flat, keyed by attribute and value. For the attributes below it, it is nested one level deeper, because whether a storage size is available depends on which colour is selected.

The template read both the same way, using the deeper path:

- $isCheckoutable = isset($precomputedData['optionCheckoutable'][$key][$color][$precomputedData['firstSelectedOptionKey']][$precomputedData['firstSelectedOptionValue']]) && ...
+ // First attribute uses simplified flat structure
+ $optionIsCheckoutable = isset($precomputedData['optionCheckoutable'][$key][$color]) && ...

On the flat structure that deep lookup finds nothing. isset returns false. False means not checkoutable, which renders as disable, which looks to a customer exactly like out of stock. A missing array key and a sold-out phone are indistinguishable on the page.

The other half of the fix is the rename. The variable had been called $isCheckoutable in both scopes, inside nested Blade blocks in the same template, so whichever value was assigned last could be the one that got read. Renaming it to $optionIsCheckoutable in both places is not cosmetic — it is removing the possibility of the two scopes talking to each other.

I do not think the optimisation was a mistake. It is the right change and the queries it removed were real. But it converted a question with a reliable answer into a question with a shape, and a shape that varies by row is a thing a template will get wrong. If I were doing it again I would give both rows the same structure even where the extra level is redundant, and pay one wasted dimension for the guarantee.

Orders that were not yours

On 26 November, a hotfix called Mixed order creation, at 210 additions. Most of it is 173 lines in the order repository, and the method names say what was wrong:

$this->validateOrderOwnership($order, $data);

/**
 * Find and validate order for the given cart.
 * This method ensures order ownership validation at repository level.
 */
public function findValidOrderForCart(?int $cartId, ?int $sessionOrderId = null): ?OrderModel

Order updates were not checking that the order being updated belonged to the cart doing the updating. The order id lived in the session, and a session outliving the cart it was created for — a customer coming back, starting again, changing device — could apply new data to an old order.

Two days later I merged a follow-up that deletes sixty-three lines and is called Using cart id. Rather than validating the ambiguous path, remove it: derive the order from the cart, which is the thing that actually knows what is being bought, and stop consulting the session for it at all.

That sequence — guard it, then delete the thing that needed guarding — is the same two-step as September's cache work, and I think it is the healthier pattern. The validation buys you safety today. Removing the ambiguity means nobody has to remember the validation.

It is also, unavoidably, the fourth appearance of the session order id in this series. February 2024: a stale order in the session between checkout attempts, cleared with one call. August 2024: that call had been clearing the wrong key for six months. June 2025: an order object one insert out of date. November 2025: an order id that no longer belongs to the cart holding it. The same piece of state, wrong in four different ways, over twenty-two months.

Senangpay is on the list

A small thing I noticed while going through the month's issues. One of them, raised on 14 November, is titled Cleanup iPay88 & senangpay.

Senangpay is the payment gateway I integrated in January 2024. The last article I wrote about my first month with it ended with a sandbox handshake and a hardcoded iPhone 12 at RM 1600, and I described it as a spike that proved the gateway would say hello. February 2024 turned it into something that could take real money and refuse a forged callback.

By November 2025 the shop runs on EGHL, and Senangpay is a directory to be removed. That issue is still open as I write this, which feels appropriate — the code that first took money from a customer is going to sit there a while longer before anybody deletes it.

The other project

Everything above is one codebase. The same month I made a hundred and twenty-one commits to something else entirely: a Discord bot with a web panel, written in TypeScript, that I started in August and released as version 3.0.0 in October.

November was the month it grew a spine. A levelling and XP system with rank-change notifications, a welcomer, booster and supporter roles, a message forwarder, feedback settings, per-server permissions, an inactive member list, a language switcher, and a move onto Neon for the database with Docker for local development. Seventeen numbered pull requests, from 1007: Bug fixes after release to 1023: Improving admin panel.

What strikes me, reading the two histories side by side, is that they are the same month twice.

3cat, October  Fix duplicate transaction     unique constraint on the payment reference
bot,  November 1014: XP duplication fix on join

3cat, November Mixed order creation          order ownership validation
bot,  November HOTFIX: Fixing Bot logs force accessed by moderator

3cat, September Remove auto trigger cloudfront invalidation  (-1151)
bot,  November  1013: Cache Removal

Duplicate records because nothing enforced uniqueness. An authorisation check missing on something a privileged user could reach. Caching machinery removed because it was causing more problems than it solved. Two codebases, different languages, different scale, no shared code — and I hit the same three classes of problem within weeks of each other.

I do not think that is coincidence, and I do not think it is bad luck either. Those are the mistakes that come from building a feature before its constraints exist: you add XP before you think about what happens when a member rejoins, you add order updates before you ask who owns the order, you add a cache before you know how often the underlying thing changes. Doing it twice in one month in two projects is the clearest evidence I have that these are my habits rather than any codebase's problem.

The bot also got a security pass in the last week of the month — a commit called Improve xss preventation & sql injection — plus two separate pull requests fixing timezones, which is its own kind of lesson about a bot serving people in more than one country.

1,323 lines that went nowhere

The attempt I skipped is the largest abandoned pull request in this series: 699 additions, 624 deletions, eleven files. Deflate it for the lock file — 468 and 465 of those lines are composer.lock — and the hand-written part is about 230 lines across the product model, the category model, the promotion class and the voucher calculator. It also deleted config/querydetector.php, sixty-eight lines, and dropped the query-detection package from composer.json: the tool I had been using to find the duplicate queries was itself on the list of things to remove.

The second attempt, a week later, went wider rather than deeper — eighteen files, 551 additions, with the promotion class taking 195 of them and new work in the cart validator, the variant selector, the product-voucher trait and the Google feed. That one is a refactor of how promotions get resolved, not a query fix.

What merged instead was 378 additions across six files. Two attempts of roughly a thousand lines between them, and the version that shipped was a third the size of either.

The four-day gap is the part I would not have noticed without going back for this. The successful pull request merged on the seventh; the two large attempts sat open until the eleventh. Nothing was blocking them — the problem was already solved. They stayed open because closing your own abandoned work is the last thing on the list, and it is the thing that makes the history readable later. I have written before about opening the same pull request three times without noticing. This is the adjacent failure: leaving the losers open long enough that the record no longer shows which one won.

A checkout you arrive at pre-filled

373 additions, 92 deletions, fourteen files, and I did not mention it. It is the most product-shaped feature of the month.

The ticket's reasoning is worth quoting because it names the actual problem:

Our checkout flow is complex and often requires customers to pay a
RM100 deposit to secure high-demand devices. To reduce friction and
prevent drop-off, the chatbot needs to generate a templated checkout
URL that preconfigures shipping method, store selection, and payment
behaviour.

So the customer talks to the AI assistant, the assistant hands them a link, and the checkout comes up with the shipping method chosen, the store chosen, split payment switched on and the first payment amount already set. A URL like ?shipping_method=paid_reservation&store_id=AEON_Bukit_Tinggi&split_payment_enabled=true&first_payment_amount=100.

Putting a payment amount in a query string is the kind of thing that deserves a hard look, and to my own surprise the validation holds up. The amount goes through FILTER_VALIDATE_FLOAT, has to be at least the minimum deposit and strictly less than the order total, and anything failing those checks is discarded in favour of the default rather than rejected with an error. Split payment is only honoured for delivery and paid reservation. And if the link asks for a free reservation on a product that is replenished or pre-orderable — which June's shipping-SLA setting says cannot be freely reserved — it silently upgrades to a paid reservation instead of failing.

That last rule is the June setting earning its keep in a place I did not build it for. The vocabulary for we can get this versus we have this existed, so a link generated by a chatbot could be corrected against it automatically.

The thing I would still flag is the deposit floor: min(100, round($total * 0.15)). Because it is min and not max, the minimum first payment is RM100 for anything above about RM670 and fifteen per cent below that. A RM6,000 MacBook and a RM700 phone both secure for RM100. That may well be the commercial intent — a flat, memorable deposit — but it is a pricing decision expressed as an arithmetic accident, and nothing in the ticket says which it is.

The feed had been reading product names

Ninety-seven additions, five deletions, one file, and it is the best small thing in November. The Google Shopping feed reports each product's condition. This is how it had been working that out:

-    explode(' ', $product->name)[0],
+    $condition,

The first word of the product's name. A product called Used iPhone 12 128GB reported its condition as "Used", which is correct entirely by accident, and any product whose name started with anything else reported that instead. The fix resolves the condition from the actual product attribute against an allow-list of ['used', 'new'], and the same pull request began excluding do-not-publish products from the feed altogether.

I have a running list of places in this codebase where absent or malformed data was assumed to be well-formed — categories[0] in March 2024, first() in July 2024, find() in December 2024, json_decode()[key] in February 2025. This is a different member of the same family and arguably the worst of them, because it is not a missing value being assumed present: it is a display string being used as a data field, on a feed that goes to an external system, where nobody at 3cat would ever see the result. Renaming a product would have changed what Google was told about its condition.

What November was

Twenty-four pull requests at work and a hundred and twenty-one commits after it. A product page that asks the database far less, an interactive map replaced by a picture of one, orders that can only be modified by the cart that owns them, a warranty portal cleaned up at nearly seven hundred additions, CatCare moved onto product pages — and a Discord bot that learned to level, welcome, moderate and speak more than one language.

The lesson I would take is about what optimisation moves rather than what it removes. The query work was correct, measured, and asked for at priority zero. It also produced the only bug this year that made us look like we had nothing to sell — because the cost of not asking the database is that you have to be right about the answer yourself, in a data structure, in a template, in two places with different shapes. Ten days and two hotfixes is a cheap price for that lesson. It was still the most expensive thing about the month.

Revised: this article originally misreported the dates of the two abandoned query-optimisation pull requests as closures when they were openings, omitted the larger of the two entirely, and did not mention the templated checkout link or the Google feed fix. The correction is marked in place and the three sections above were added from the original diffs.