Dansday

The Month the Gateway and the Shop Disagreed

Published on Oct 31, 2025

Eighteen pull requests at 3cat in October, seventeen merged, plus twenty-six commits on a Discord bot of my own. Most of the work traces back to one sentence in a priority-zero ticket — and roughly fifteen hundred changed lines of it went to the product page, which I left out of this article the first time.

EGHL says paid, the shop says pending

There have been multiple issues of payment discrepancies between EGHL
and Bagisto:
- Some payment transactions are captured on Bagisto, but not reflected
  in Bagisto order statuses
- In other cases, payment statuses recorded in EGHL do not match
  Bagisto's transaction records (e.g., EGHL shows success but Bagisto
  remains pending/failed).

This has led to reconciliation challenges, customer complaints, and
manual investigation overhead.

That is the worst class of bug this application can have. Not a crash, not a wrong price — a disagreement between the payment gateway and the shop about whether a customer has paid. The gateway has taken the money. The order sits in the admin panel looking unpaid. Somebody has to go and check by hand, and until they do, a customer who has paid is being treated as though they have not.

Two things caused parts of it, and I filed both as my own issues on 2 October.

Nineteen months without a unique constraint

The first was duplicate transactions. The fix is a migration, seventy-seven lines and no deletions:

$duplicates = DB::select("
    SELECT transaction_id, COUNT(*) as count
    FROM order_transactions
    GROUP BY transaction_id
    HAVING count > 1
");

The order_transactions table stores the gateway's own reference for each payment, and it had no uniqueness on that column. So the same gateway transaction could be written more than once.

The mechanism is one I described in this series in February 2024, from the other end. I wrote then that a payment callback arrives as a URL in the customer's browser, which is why you have to verify its hash rather than trust it. What I did not think about is that a URL in a browser can be requested twice — a refresh, a back-and-forward, a flaky connection retrying — and each request recorded another transaction row against the same order. Verifying the callback was necessary. Making the handler idempotent was the other half, and it took twenty months to arrive.

The migration cannot simply delete the extra rows, because they are payment records. So it renames them:

// Keep the first one unchanged, append _2, _3, etc. to the rest
$counter = 2;
foreach ($records as $index => $record) {
    if ($index > 0) {
        DB::table('order_transactions')->where('id', $record->id)
            ->update(['transaction_id' => $duplicate->transaction_id . '_' . $counter]);
        $counter++;
    }
}

Schema::table('order_transactions', function (Blueprint $table) {
    $table->unique('transaction_id');
});

Preserve the data, disambiguate it, then add the constraint that should have existed since the table was created.

The cost of that choice is worth stating: those renamed rows now carry a transaction reference the gateway never issued. Anybody reconciling against EGHL will find abc123_2 in our records and nothing matching it on their side. It is the right trade — you cannot delete payment history, and the constraint has to go on — but it means the historical data is now slightly fictional in a way that a future reconciliation script will have to know about.

Updating an order deleted its invoice

The second issue was invoices going missing, and the cause was sitting in the order repository in plain sight:

foreach ($order->items() as $item) {
    $item->invoice_items()->delete();
    $item->save();
}

Every update to an order deleted the invoice line items attached to it. Change an address, change a status, and the invoice quietly loses its contents. The invoice I was so pleased with in February 2024 — 434 lines of PDF template, a document a customer keeps — can be emptied by an unrelated edit.

What I shipped is not the fix. It is logging:

$invoiceItemsCount = $item->invoice_items()->count();
if ($invoiceItemsCount > 0) {
    Log::info('Deleting invoice items for order item', [
        'order_id'            => $order->id,
        'order_item_id'       => $item->id,
        'invoice_items_count' => $invoiceItemsCount,
        'sku'                 => $item->sku,
    ]);
}

Record every time it happens, with enough detail to find the orders affected, before changing behaviour that other code may depend on. That is the correct order of operations on a live payments table and it is also an admission: on 15 October I knew a destructive line was running and chose to watch it rather than stop it.

The same pull request did fix something real. The order update logic existed in two places, and both were creating address records, so orders were accumulating duplicate addresses. Fourteen lines came out of one of them.

A logger for the money

The priority-zero ticket's own answer was instrumentation: a PaymentLogger class of 102 lines, and logging threaded through seven files — the payment gateway controller, the order controller, the confirmation page controller, the EGHL adapter, the order repository and the transaction processor. 452 additions.

There is nothing clever in it. It is the recognition that when two systems disagree about money, the only way to find out which one is wrong is to have written down what each of them said at the time. Six months of reconciliation done by hand, and the fix is not a fix at all — it is making the next occurrence diagnosable in minutes rather than by asking somebody to compare two dashboards.

Alongside it, a priority-zero database query optimisation pull request, 699 additions and 624 deletions across eleven files, which I closed without merging.

The pixel moved to the server

The month's other large thread was advertising attribution, and it followed the same shape as the payment work: something was quietly wrong, and finding out required rebuilding how it reported.

(BUG) Suspect customer email is not being sent correctly for the purchase
event (for website + POS). For website this matching is important as we
already capture correct customer info.

Meta matches a purchase to an ad click using customer identifiers, and if the email is not arriving correctly the match fails — so the advertising looks like it produced nothing. Which is the same failure as December 2024's first-click attribution, where a fifth of the traffic came from paid search and not a single order was credited to it.

The fix moved the reporting from the browser to the server. First 163 lines of the old integration came out. Then a ConversionApiService of 373 lines went in, with an API controller, a page-view middleware, and changes to the shared JavaScript. Server-side conversion reporting is more reliable for the obvious reason — it does not depend on a script surviving in the customer's browser — and it is more work, because the server has to assemble the identifiers a pixel used to pick up for free. A follow-up pull request added the email, IP address and user agent to the payload.

A portal for warranties

Quietly, across three pull requests, 3cat gained a warranty portal: a place for a customer to see the devices they have registered, with their coverage. Redeploy and bug fixes on the seventeenth, more fixes on the twenty-second, and then listing activated devices at 286 additions on the twenty-ninth.

Every device this company sells comes with a one-year warranty — that has been in the copy since the first month I worked here, and I have written the words 1-year warranty into translation files more times than I can count. Twenty-two months later there is a page where a customer can look it up. The warranty existed as a promise long before it existed as a record.

Un-selecting the nearest store

In August I wrote approvingly about auto-selecting a customer's nearest store at checkout. I contrasted it with May, when the same geolocation capability ran on every page for a case that almost never happened, and said: same capability, opposite judgement.

On 31 October I removed it. Seventeen additions, thirty-nine deletions in the cart controller, and thirty lines out of the checkout controller.

When reservation option is selected, default list to 'All Stores'.
No store is selected by default. If user attempts to continue
reservation order without selecting a store, proceed by marking it
as 'Low Yat CS1'

No pre-selection, no location lookup. If the customer does not choose, the order is assigned to the flagship store.

I do not know whether guessing was annoying customers or sending reservations to the wrong shops, because the ticket does not say. That sentence was wrong when I wrote it. The answer was already in the repository, three weeks earlier, and I describe it below. What I can see is the trajectory: three attempts in six months at using a visitor's location, and the version that survived is the one that does not use it at all. I praised the August version in writing two months ago. It is worth recording that it is gone.

Version 3.0.0 of something else

Away from work, October was the month a side project became a real one. On 16 October I tagged version 3.0.0 of a Discord bot I had started in August — a TypeScript application with a web panel, built on the premise that server owners should configure things in a browser rather than by typing slash commands into a channel.

The first half of the month is the release: 1001: Initial release bot commands, a redesigned panel, customisable embed messages, role mentions, timeouts. The commits from 15 October are the unglamorous half of any release — Remove unused depndencies, Fix package json, Fix package json (2), Add secret key and env, Remove chatbot.

Then on 31 October, twelve commits in a day: moderation, per-role permissions, an inactive member list, booster and supporter roles, a feedback feature, emoji support, and a global footer config. Also Remove duplicated logger & unused code and Fixing custom role permission access.

That last one is worth pausing on, because it is the same category of problem as the payment work above — a permission check on a thing a privileged user could reach. Different codebase, different language, same week.

The guess overrode the choice

On 8 October, twenty-three days before I removed the nearest-store feature, a colleague filed a bug: When customer reserves to pick up at a store on PDP — the store is not auto-selected at checkout. Two screenshots, no prose.

That is the answer to the question I said the ticket did not answer. A customer picks a specific shop on the product page. They arrive at checkout. The August geolocation feature runs, decides which shop is nearest to their IP address, and selects that one instead. The customer's explicit choice loses to the system's inference.

The fix is twenty additions and two deletions in the cart controller, and it is a single early return:

const urlParams = new URLSearchParams(window.location.search);
const placeIdFromUrl = urlParams.get('place_id');
const stateFromUrl = urlParams.get('state');

if (placeIdFromUrl) {
    // honour the store the customer already picked
    return;
}

If the URL carries a store, believe the URL and do not geolocate. Which is correct, and which also states the whole problem in one line: the feature had no notion of the customer having already answered the question it was about to guess at.

So the trajectory I described at the end of this article is worse than I made it sound. In May I ran geolocation on every page view and reverted it. In August I moved it to the checkout, argued in writing that this was the right place for it, and called it same capability, opposite judgement. In October it was found overriding customers' explicit selections, patched on the eighth, and removed entirely on the thirty-first. Three weeks between the patch and the deletion. I had the evidence for why it went and reported that I did not.

Fifteen hundred lines on the product page

Two pull requests I did not mention at all, and together they are the largest thing in October after the payments work: 627 additions and 262 deletions on the eighth, 330 additions and 270 deletions on the thirty-first.

Those numbers need deflating before they mean anything. Both diffs are dominated by tailwindcss-sprites-utilities.json, a generated file the sprite build produces and we commit — 268 lines of it in the first, 522 in the second. The hand-written part of the first is about 458 lines of template and JavaScript; the second is 59 lines of Blade. This is the same caveat I applied to the December voucher work, where most of a six-thousand-line diff was lock files, and I should apply it consistently: committed build artefacts make every diff that touches an icon look like a rewrite.

The first, Clean up Trade In + UVP Section on PDPs, put an image-count indicator over the product photo, enabled swipe on mobile, and moved the two value claims — one-year warranty and Great Condition — onto the image frame itself with large tap zones opening the existing slide-outs. It deleted uvp-detail.blade.php and gave the warranty and Great Condition panels their own files. It also turned the trade-in figure into a WhatsApp link that opens with the message Hi Teega, I want to trade in my phone already typed.

The Great Condition slide-out getting its own file is worth noting for a reason that had nothing to do with October. That panel is where the shop explained what Great Condition meant, and it is one of the files rewritten in January 2026 when the phrase was replaced with 56-step checks. October made the claim more prominent — onto the image, one tap away — fifteen months into a thread about the shop's copy promising more than it could show. Making a vague claim easier to reach is not the same as making it true.

The second, on the last day of the month, rebuilt the instalment block on the product page, and contains one line I would argue with:

-  Sehingga 36  bulan
+  @if($product->new)
+      Sehingga 36 bulan
+  @else
+      Sehingga 24 bulan
+  @endif

The instalment term now depends on whether the product is flagged as new — thirty-six months if it is, twenty-four if it is not. That is a real financing rule, and it is expressed as two hardcoded Malay strings inside a template, chosen by a boolean, with no reference to whatever the banks actually offer. It is the same pattern as the warranty percentage I hardcoded in February 2024 and have never given a real source: a number the business depends on, living in the presentation layer because that is where it was needed first.

The same pull request added a Google Analytics action for the JCL instalment option, which is the small useful half of it — the shop can now tell which financing partner people actually tap.

What October was

Eighteen pull requests at work and a bot release of my own. A unique constraint that should have existed since February 2024, a destructive line now under observation, payment logging across seven files, advertising that reports from the server, a warranty a customer can look up, one guess removed, and version 3.0.0 of a Discord bot.

The pattern I would name is that this month was almost entirely about knowing things. Not a single one of the payment problems was hard to fix once identified — a database constraint, a log line, a removed duplicate. They were hard to find, because a shop that has taken RM 999 from somebody and recorded it as pending does not raise an exception. It just sits there being wrong until a human notices. Twenty-three months in, the most valuable code I wrote this month does nothing except write down what happened.

Revised: this article originally described the payments work well and the product page not at all — it omitted two pull requests worth roughly fifteen hundred changed lines, and the reservations bug that explains why the nearest-store feature was removed. It also claimed I could not tell why that feature went, when the evidence was in the repository three weeks before I wrote it. The two sections above were added from the original diffs and the correction is marked in place.