Twenty-five pull requests in July and seventy-seven contributions — my busiest month since October 2024. One feature accounts for nine of them: eight to fix it, one to build it.
RM800 off, for pickup only
3cat partnered with XOX, a Malaysian mobile operator. The deal is straightforward and the framing in the ticket is what makes it interesting:
We've partnered with XOX to offer customers RM800 off their device and
unlimited data and calls. This intended to help boost conversions.
Online's role is to generate high-converting leads and direct them to
our stores to complete the sale.The website's job here is explicitly not to sell anything. It is to capture somebody who wants the bundle and send them to a shop, because signing a customer onto a mobile plan involves paperwork a checkout page cannot do. Every part of the implementation follows from that: the XOX section is marked For Pick Up Orders only, and the order summary gains a line that reads Unlimited Data & Calls — Not Added until the customer toggles it.
1,184 additions across thirty-eight files, merged 24 July. A sign-up panel above the shipping section, a sidebar on the product page, changes to the price breakdown, the pickup customer form, and 183 lines in the cart controller.
An identity card is a formatted string
Putting somebody on a phone plan in Malaysia means collecting their NRIC — the national identity card number. So the checkout grew a field for it, with a format:
static NRIC_REGEX = /^\d{6}-\d{2}-\d{4}$/;Twelve digits in three groups: date of birth, place of birth, and a serial. Rather than asking people to type the dashes, the field inserts them:
handleNricInput(event) {
// Remove all non-digit characters
let value = event.target.value.replace(/\D/g, '');
// Limit to 12 digits
value = value.slice(0, 12);
// Insert dashes after 6 and 8 digits
if (value.length > 6) { ... }
}Strip everything that is not a digit, cap the length, put the separators back. It is the right way round: accept whatever the customer types, and be strict about what you store.
The validation is conditional, which took a moment to get right. The NRIC is only required if the customer has opted into XOX, and unchecking the toggle clears both the field and any error attached to it — otherwise somebody who ticks the box, mistypes their number, then changes their mind is left looking at a red error on a field that no longer applies to them.
I like this ticket because it is the first time in twenty months that this codebase had to know something about Malaysian law rather than Malaysian addresses. A postcode is a formatting problem. An identity card number is a regulatory requirement wearing a formatting problem's clothes.
The discount was a voucher
Here is the decision that shaped the rest of the month, and it is one line:
public const string XOX_TARGET_CODE = '800XOX';The RM800 off was not implemented as a new kind of discount. It was implemented as a voucher code, applied through the same cart rule engine as every other promotion on the site.
That is a defensible choice — arguably the right one. The voucher system already knew how to attach a discount to specific products, validate conditions, and show the reduction in the price breakdown. Building a parallel mechanism for one partnership would have been the November mistake of preferring machinery to reuse.
It also means the XOX bundle inherited everything the voucher system is. And by July 2025 the voucher system was the most collision-prone code in the application. I built the first version in October 2024 and hand-rolled a partial evaluator for cart rule conditions, and I wrote at the time that it would drift. In December it turned out to be ignoring its own operators, so exclusions applied backwards. In April a colleague's fulfilment feature broke vouchers on pickup orders badly enough that I reverted the whole thing rather than keep patching. In July a telco partnership arrived as a coupon code and hit the same surface again.
Six production fixes in five days
The branch names tell the story without any help from me:
1437-production-fix Fix product not eligible xox
1437-production-fix-2 Handle page for not eligible xox
1437-production-fix-3 Handle NRIC input on first load (one attempt closed)
1437-production-fix-4 Dynamic voucher discount for reservation
1437-production-fix-5 Fix voucher store popup on fe
1437-production-fix-6 Fix voucher validation on manual applyPlus a separate pull request the day before the feature merged, called Item adder, at 123 additions.
Three of those six are voucher problems. The reservation confirmation popup needed to show the XOX discount dynamically rather than a static figure. The surprise-voucher popup from December was surfacing when it should not. And manual voucher application — a customer typing a code themselves — validated wrongly once an XOX voucher could also be in play.
The other three are eligibility. Not every device qualifies for the bundle, so there is a product-not-eligible case, and then a whole page state for a customer who has landed on a checkout for a device that cannot have the deal. That is the kind of branch that is invisible until real traffic arrives, because in testing you use a product from the list.
Six fixes in five days on a feature that reviewed cleanly is not a failure of review. It is what integrating with an external partner's rules looks like: the eligibility list lives somewhere else, the customer behaviour is new, and the interactions with your own existing discounts only appear in combination.
A form field is not an input
One of those fixes is three lines and worth pulling out.
The NRIC field was not showing correctly on first load. The cause was where the controller's hook was attached:
- target="data-field-target='pickupInput xoxNric' data-cart-target='xoxSelected'"
+ target="data-field-target='pickupInput xoxNric'"
+ wrapperTarget="data-cart-target='xoxSelected'"The show-and-hide target had been on the input element itself, and the shared text-input component gained a new wrapperTarget attribute so it could go on the surrounding div instead.
The reason is that a form field is not an input. It is a group: a label, the input, and an error message. Toggling visibility on the input alone leaves the rest of the group behind, and the fix was to give the component a way to expose its wrapper. Nineteen months earlier my first substantial task on this codebase was tearing pages into reusable Blade components and giving them sensible props. This is what maintaining that library looks like — a new requirement arrives, and the component needs one more hook it was never designed to have.
Hiding is not the same as not rendering
My favourite bug of the month is six lines and it closes a loop with the first article in this series.
Product pages were returning 500 errors when a variant had no special price. The sticky price header looked like this:
<div class="{!! empty($saving) ? 'hidden xl:hidden' : '' !!} hidden xl:flex ...">
{!! trans('web.save') . ' ' . Option::formatPrice($save) !!}
</div>The conditional controls a CSS class. It decides whether the element is displayed. It does not decide whether the contents are evaluated — so Option::formatPrice($save) ran regardless, on a variant where $save was empty, and threw. The customer got an error page in place of a product, and the element that caused it was one the page had already decided not to show them.
+ @if(!empty($saving))
+ <div class="hidden xl:flex ml-2 saving ...">
+ {!! trans('web.save') . ' ' . Option::formatPrice($save) !!}
+ </div>
+ @endifIn December 2023, in the first month of this job, I wrote about the discount badge on the product page and the two conditionals I added so it would stop announcing savings that did not exist. One of them was @if(!empty($saving)), and I described it as the page learning restraint.
Nineteen months later, on the same page, for the same variable, somebody — possibly me — had written the CSS-class version instead. It looks equivalent. It reads as hide this when there is no saving. It is not equivalent, because Blade evaluates what is inside the element either way, and a hidden element with a broken expression in it is still a broken page.
A job that dispatched itself twice
Two pull requests on the same problem, one abandoned at 36 additions and one merged at 18, and neither is in this article. They are the cheapest lines of July and they belong to a thread I have been tracking for a year.
In June a pull request titled after three cosmetic tickets quietly introduced HandleLivestreamTransitionsJob — the job that spots a livestream starting or ending and invalidates the cached pages mentioning it. This is how it had been registered:
- \P3cat\Jobs\HandleLivestreamTransitionsJob::dispatch()->onQueue('default');
- $schedule->job(new \P3cat\Jobs\HandleLivestreamTransitionsJob)->everyFiveMinutes();
+ $schedule->job(new \P3cat\Jobs\HandleLivestreamTransitionsJob(1, 'en'))->everyFiveMinutes();
The first line dispatches the job. The second schedules it every five minutes. But schedule() is evaluated every time the scheduler runs, so the first line fired a job on every pass, on top of the scheduled one. The fix deletes the stray dispatch and passes the channel and locale in explicitly rather than letting the job guess.
Cache invalidation on this site is a metered CloudFront API, which I had written about two months earlier as a cost problem. So a duplicated dispatch is not a tidiness issue, it is a bill. And it went in under a title I could not find later: Fix livestream invalidation, twice, because the first attempt was abandoned and reopened.
Set that beside what I found later. In August the same invalidation system turned out to have been clearing only one of two URL forms for every path since November. In September I stopped defending it and opened a pull request deleting 1,151 lines. The story I have told is that the response cache was abandoned because it cost too much and returned too little. July is a data point for a blunter version: it was abandoned because nobody, including me, was keeping track of what triggered it.
Four strings for one promise
Fifty-one additions, fifteen deletions, four files, titled FIX: Shipping Guarantee Messages. It is the checkout telling a customer when they will get their device, and the fix is not the interesting part — the shape of it is:
guaranteeTimeReady: String,
guaranteeTimeReplenished: String,
guaranteeTimeReady2: String,
guaranteeTimeReplenished2: String,
Four values, chosen at runtime by two booleans: whether the item is replenished stock, and whether this is a reservation or a delivery. The suffix 2 is the only thing distinguishing one pair from the other.
The promise those strings make is the one I had built the vocabulary for in June, when operations got to declare per product whether something was ready stock, readily replenished, or pre-orderable. In June I wrote that giving the system that vocabulary was the point. In July the vocabulary is being consumed by four template variables whose names carry a digit instead of a meaning. Whoever changes the delivery promise next has to work out which 2 they want.
Also unmentioned: the floating chat button's prefilled WhatsApp message was updated for checkout and product pages, twice in the same month, and a new homepage tile was added to the More Devices block.
What July was
Twenty-five pull requests, twenty-three merged. A mobile plan you can add to a phone, a national identity number the checkout knows how to format, and a product page that stopped erroring on devices with no discount.
The XOX integration is the thing I would talk about, but not for the feature. It is the clearest illustration of a cost that does not appear on any ticket: I made a reasonable decision in October 2024 to implement discounts by hand-reading the cart rule engine's conditions, and every promotional mechanism the business has invented since — delivery vouchers, store vouchers, surprise vouchers, a telco bundle — has arrived through that same doorway and widened the crack. Six production fixes in five days is what that costs now. It will be more next time.
Revised: July was my busiest month to that point — twenty-two merged pull requests — and this article originally described about a dozen. It omitted the livestream invalidation fix, which belongs to the cache thread, and the shipping-guarantee work. The sections above were added from the original diffs.