Dansday

The Month Out of Stock Became Pre-Order

Published on Jun 30, 2025

Fifteen pull requests in June and every one of them merged — the second month in a row with nothing abandoned. Forty-six contributions. Two real features, one very small bug that stopped orders being marked as paid, and a piece of technical debt I predicted in March arriving exactly where I said it would.

Out of stock became pre-order

The ticket that mattered most this month quietly resolves something I have written about three times.

Many customers invest time comparing prices and device conditions before
deciding to buy, only to encounter frustration when their chosen item is
marked as out of stock. However, in many cases, our operations team can
still source these devices quickly from nearby suppliers.

The answer was a shipping SLA setting per product, chosen in the admin panel from three options: Ready Stock Only, Readily Replenished, and Allow Pre-Order. 356 additions across twenty-three files, merged 26 June, with a two-line production fix the same day.

I want to put this next to what came before it. In November I shipped an out-of-stock state that displayed a Low Stock badge and the words is very popular and almost gone! on products whose quantity was zero, with a chat button to ask whether any existed. I wrote at the time that it was a softer version of the truth and that nobody had paused over it, including me. In March I noted the same instinct in Rare Gem! for products nobody had bought. In April, a timer that watched how long you hesitated.

Pre-order stock is the version of that problem solved properly. Instead of finding a kinder way to say we do not have this, the shop changed what it could promise: operations declares per device whether it can be replenished, and the page tells the customer the actual arrangement. The copy becomes true because the fulfilment model changed underneath it, which is the only way that ever works.

It took eight months to get from the badge to the SLA setting. I do not think the badge was the wrong thing to ship in November — the alternative that week was a page with a missing button. But it is worth noticing that the honest version required a change to how the business buys phones, not to how the page describes them.

One order, two payments

The month's largest feature was split payments — 846 additions, 252 deletions, forty-one files, plus a 395-line interface pull request the week before.

Many of our customers, especially those purchasing higher-value devices,
have requested more flexible payment options. Introducing a Split Payment
feature allows them to break their purchase into two separate payments
using different payment methods.

Not instalments, which the site already had. Two genuinely separate transactions on one order, using two different methods — part on a card, the rest by online banking — because a customer buying a MacBook may have the money across two places.

It needed its own controller, its own page, its own payment-method component and changes to the order model. The reason it is a real engineering problem rather than an interface one is that an order now has a partial state that has to survive a round trip to a payment gateway. Payment one succeeds. The customer goes off to a bank's website for payment two. That may succeed, fail, or never come back at all, and the order has to be correct in all three cases.

The order that did not know it was paid

Which is exactly what broke, ten days later, in eight lines:

- if ($status === OrderTransaction::STATUS_PAID && $this->isOrderFullPaid($order)) {
-     $this->orderRepository->updateOrderStatus($order, OrderModel::STATUS_PROCESSING);
+ if ($status === OrderTransaction::STATUS_PAID) {
+     $order = $this->orderRepository->find($order->id);
+
+     if ($this->isOrderFullPaid($order)) {
+         $this->orderRepository->updateOrderStatus($order, OrderModel::STATUS_PROCESSING);

A few lines above this, the same method creates the transaction record for the payment that just succeeded. Then it asks the order whether it is fully paid. But $order is the object that was passed in, and its list of transactions was loaded before the new one existed. So the sum it checks against is missing the payment it is currently processing.

With a single payment this never mattered, because a lone transaction covering the whole amount would still have to clear the check — and it did, because the code had been written when the only possible answer was all-or-nothing. Split it in two and the arithmetic starts depending on a relation that is one insert out of date. Both payments succeed, the customer sees both confirmations, and the order sits in the admin panel not marked as processing.

The fix is to re-read the order from the database before asking it anything. One line, and it is the same mistake as November's cache invalidation, where I looked for a product variant in a loaded relation while the variant was in the middle of being created. In-memory models are snapshots. I know that. I have now shipped it twice.

The same form, still twice

In March I fixed a bug where a customer's name and email vanished when they toggled between delivery and reservation, because the checkout has two separate sets of contact fields with different element ids and no relationship between them. I copied the values across on toggle:

const fieldTypes = ['first_name', 'last_name', 'email'];

I wrote then that this was the second-best fix, that one shared set of fields would be correct, and that what existed instead was the same facts in two places kept in step by code that has to remember to run.

On 18 June: Bug: Must persist WhatsApp number when switching between Delivery to Reservation.

The phone number was never in that array. And the fix does not add it — it uses a completely different mechanism, in a different controller, mirroring the value on every keystroke:

handlePhone(event) {
    this.handleInput(event, /[^0-9+]/g);

    const value = event.target.value;
    if (event.target === this.shippingPhoneNumberTarget && this.hasPickupPhoneNumberTarget) {
        this.pickupPhoneNumberTarget.value = value;
    } else if (event.target === this.pickupPhoneNumberTarget && this.hasShippingPhoneNumberTarget) {
        this.shippingPhoneNumberTarget.value = value;
    }
}

So the duplicated form is now kept in sync by two different strategies in two different files: names and email copied when you toggle, phone mirrored as you type. The same pull request also had to teach the local-storage save to include the pickup fields, having previously special-cased only the shipping phone.

This is what deferred structural work looks like when it comes due. The March decision was defensible — unifying the fields would have touched delivery, pickup, full-payment reservation and every validation rule, with the shop open. Three months later the cost of not doing it is a second sync mechanism, a third place that knows about the duplication, and a bug that a customer had to report.

The popup learned to wait

April's exit-intent popup got throttled, and the ticket does not soften it:

The Exit Intent popup shows up too frequently, and too early, which is
distracting to the navigation experience.

The new rules are specific in a way that suggests somebody watched it happen: six seconds on a page before it may fire at all, then a scroll-up on mobile or the cursor travelling to the close button on desktop; or, failing that, seventy-five seconds of inactivity on a product page and forty-five on the checkout. 504 additions and 392 deletions to make something happen less often.

Two months earlier I built the cumulative timer to detect hesitation and interrupt it. June was spent deciding how long hesitation has to last before interrupting is welcome. Those are the same feature having an argument with itself, and the resolution is a set of thresholds somebody will revisit again.

The chat button, again

On 13 June, a hotfix: WA CTA must always be hidden when pop ups are surfaced. Seven lines.

In May I merged a pull request called Ensure Chat CTA does not overlay Slideout / Pop Up. In January 2024 — the second month of this whole series — I fixed a chat widget floating over the sticky product specification bar.

The floating WhatsApp button has now collided with something in three separate months across eighteen months. It is not a hard bug; each fix is a handful of lines. It keeps happening because the site kept growing new things that appear on top of the page — a specifications bar, a slideout, an exit-intent popup, a CatCare popup, a livestream header — and the button was written before most of them existed. Nothing owns the question of what sits above what.

The pull request called "UI"

The second-largest thing that merged in June, 395 additions and 50 deletions across twelve files, and its title is UI. That is why it is missing from this article: I went looking for June's split-payment work by name and this is not called that.

It is the split-payment page. A new component, payment-methods-split.blade.php, 112 lines from nothing; 196 additions to the split-payment view; 25 more in the split-payment controller; and seven lines out of the storefront routes. Alongside the 846-line feature pull request I did describe, this is the second half of the same month's work — the page a customer lands on to make the second payment.

I am recording it mainly as a note about my own record-keeping. Nine months of these articles are reconstructed from pull request titles, and a title of UI on a 445-line change to the payment flow makes that reconstruction quietly wrong. The month looked like one big split-payment pull request and some small fixes. It was two big ones.

Shuffling the testimonials

Six additions, one deletion, one file, and it is the smallest change in June with the most to say:

$home = config('home');
if (isset($home['customer_stories']['testimonials'])
    && is_array($home['customer_stories']['testimonials'])) {
    shuffle($home['customer_stories']['testimonials']);
}

The homepage testimonials are a hardcoded array in config/home.php, which I reworked in February without mentioning where they lived. In June they got shuffled, so the order varies and the section looks like it is drawing from something larger than it is.

There is a second-order problem I did not notice at the time. In June the homepage was still behind the response-cache middleware I built in November 2024. A shuffle that runs while rendering only produces a new order when the cache misses — so for most visitors the "random" selection was whatever order the last cache fill happened to produce, held for as long as that entry lived. The feature and the cache were working against each other, and neither ticket knew about the other.

Three tickets, one pull request, and a job that would misbehave

148 additions, 27 deletions, nine files, titled 1385, 1386, 1387 Tickets. Three unrelated pieces of work in one branch: a new burger-menu icon, scroll-instead-of-jump behaviour for the burger menu and the stores button, and updated store and map images.

Except it is four pieces, because the same pull request adds a brand-new 93-line file, HandleLivestreamTransitionsJob.php, and wires it into the scheduler. That job is the thing that notices a livestream has started or ended and invalidates the cached pages that mention it.

In July I would have to fix that job, because it was being dispatched immediately and registered as a five-minute schedule, so it ran far more often than intended against a billed invalidation API. A background job that costs money per run, introduced inside a pull request named after three cosmetic tickets, is exactly the shape of change that gets reviewed for the wrong things.

What June was

Fifteen pull requests, all merged. An order that can be paid twice by two methods, out-of-stock devices that can actually be bought, a popup that waits six seconds, and a phone number that survives changing your mind.

The two I would put together are the pre-order setting and the phone number. One is a business changing its supply arrangements so the website can stop being evasive. The other is me paying interest on a shortcut from March, in a currency I predicted at the time. Eighteen months in, that is roughly the split of this job: some of it is genuinely new capability, and some of it is the accumulated cost of every reasonable decision made under time pressure, arriving on a schedule you do not control.

Revised: this article originally missed a 445-line pull request titled "UI" that was really the split-payment page, the testimonial shuffle, and the pull request that introduced the livestream invalidation job I would have to fix a month later. The sections above were added from the original diffs.