Five Releases in a Week, and Almost None of It Was Features
Our refund code assumed every currency uses two decimal places. Most do. Japanese yen does not, it uses zero, and Kuwaiti dinar uses three. Which means that until this week, a refund issued in yen would have gone out at a hundred times the correct amount, and one in dinar at a tenth of it.
Nobody wrote that on purpose, obviously, but somebody wrote it. It sat there working correctly for every currency we personally use every day, and wrong for the ones we do not. That is the sentence I keep coming back to while I write this, more than any changelog line.
This week our team shipped release notes for nine products across five product lines, in one wave: Eventonomy 1.3.0, WP Sell Services 1.3.0, WB Listora 1.3.0, Learnomy 1.8.0, and WB Gamification 1.6.4. If you scan the version numbers you would guess it was a features week.
It was not. Almost none of it was new functionality. Overwhelmingly it was us finding out that money was being calculated wrong, that data was visible to people who had no business seeing it, that two clocks inside the same plugin disagreed with each other, and that some settings on the screen did nothing at all when you clicked them.
I want to write about that honestly, because the tempting version of this post is “look what we shipped.” The truer version is: here is what you only find out once real people run your software with real money, in real currencies, in real timezones, at a scale you did not personally test.
Five releases, and I am not proud of most of the reasons for them
Here is the roundup, for the record, because the version numbers matter to people running these products:
- Eventonomy 1.3.0, event ticketing and organizer payouts
- WP Sell Services 1.3.0, marketplace services and vendor commissions
- WB Listora 1.3.0, listings, credits, and coupons
- Learnomy 1.8.0, the LMS and gradebook
- WB Gamification 1.6.4, points, streaks, leaderboards, credentials
Five different products, five separate codebases, and yet the same shape of problem kept showing up in each one. That is the part I actually want to talk about, not the individual fixes.
I have written before about what a couple of these look like from a developer’s side, the hooks and REST surface in Eventonomy and the extension seams in Learnomy. Those posts are about what you can build on top of. This one is about what happens underneath once somebody actually does.
Money was wrong in three products, and wrong in three completely different ways
Eventonomy’s refund math assumed every currency uses two decimal places. Most do. Dollars, euros, rupees, two decimals, and the math works fine. Yen uses zero. Dinar uses three. Get that assumption wrong and a yen refund comes out a hundred times too large, and a dinar refund comes out ten times too small. Same bug, opposite direction, depending purely on which currency the organizer picked at setup.
There was a second, separate bug sitting right next to it in the same refund flow. When an order got refunded, Eventonomy voided the attendee tickets tied to it, but only the first hundred. On a small order that is every ticket, so nobody would notice. On a bigger order, everyone past the hundredth kept a valid, scannable ticket after the money had already gone back.
WP Sell Services had its own version of the same category of problem, spread across every payment path we support. Commission splits were saving as NULL on some orders across WooCommerce, EDD, SureCart, FluentCart, and Stripe Connect, meaning the vendor’s share of an order just was not recorded.
Separately, PayPal payouts settled outside the wallet ledger entirely, which meant a vendor could, in the wrong sequence of events, get paid twice for the same work. And refunds were reversing a vendor’s ledger balance even in cases where the payment gateway never actually pulled the money back.
WB Listora’s money bugs were about timing and concurrency rather than arithmetic. The payment webhook marked a purchase complete before the credits actually landed in the account, so if the credit step failed after that point, the member was charged and had nothing to show for it.
Two refund requests fired at nearly the same moment on the same purchase could both succeed, refunding the same money twice. And single-use coupons were not actually single-use if two people, or two tabs, submitted them at close to the same instant.
Laid out next to each other, the pattern is obvious in a way it never is when you are staring at one product’s codebase in isolation:
| Product | What was wrong | Who would have noticed |
|---|---|---|
| Eventonomy | Refund amount wrong on any currency without two decimal places (100x too large on yen, 10x too small on dinar) | An organizer selling tickets priced in yen or dinar, refunding a real order |
| Eventonomy | Refund only voided the first 100 attendee tickets on an order | An organizer with a single order over 100 attendees, refunding it |
| WP Sell Services | Commission splits saved as NULL on some orders across five payment paths | A vendor comparing their payout total against their own order records |
| WP Sell Services | PayPal payouts settled outside the wallet ledger, risking a double payout | A vendor paid via PayPal in the wrong sequence of events |
| WP Sell Services | Refunds reversed a vendor’s ledger even when the gateway never reclaimed the money | A vendor whose gateway retried or delayed the actual reclaim |
| WB Listora | Purchase marked complete before credits landed, so a failed credit step left a charge with nothing delivered | A member whose credit step failed right after paying |
| WB Listora | Two concurrent refund requests on one purchase could both succeed | Someone who tapped refund twice, or two support agents acting at once |
| WB Listora | Single-use coupons redeemable more than once under concurrent submission | Anyone submitting the same coupon from two tabs or two devices |
Look at that middle column and then the last one. None of these are things you would catch by testing the happy path once, carefully, in one currency, alone, on a fast connection. Every one of them needed a specific person, in a specific situation, doing something slightly outside what we personally tried before we shipped.
The timezone problem deserves its own section, because it is the most instructive one
WB Gamification had several features that compared timestamps using the database server’s clock instead of the site’s configured timezone. If your site happens to run on UTC, you would never have seen any of this. We build and test in a small number of timezones. Most of our customers do not live in those timezones, and the bug proves it.
Once a site sat on either side of UTC, things broke in opposite directions depending on which side. A few examples, because “clocks disagreed” undersells how varied the damage was:
- Challenges showed as still open on the screen while quietly awarding nothing for hours, because the award logic and the display logic were checking time against different clocks.
- The weekly digest email went out to the wrong set of recipients, because the query that picked who should get it used a different clock than the query that picked what content to put in it.
- The kudos cooldown, the thing meant to stop someone spamming kudos, never applied at all on sites running behind UTC. It just silently did not fire.
- The leaderboard snapshot emptied itself on every rebuild on sites ahead of UTC, so every leaderboard view fell back to a full table scan instead of reading the cached snapshot, without anyone configuring that on purpose.
I want to be precise about what kind of mistake that is, because it is not the kind our QA process is built to catch. It is not a typo, it is not a missing null check, it is not even really a coding mistake in the usual sense. It is a testing-assumption mistake. We tested with our clock. Customers run with theirs.
Data was visible to people who had no business seeing it
WB Gamification had three separate paths where a logged-out visitor could read member data just by guessing an id in a request. The worst of the three let the site be made to issue a signed, verifiable credential about a person who had never shared anything with anyone. A credential with someone’s name on it, generated without their involvement, and cryptographically signed as if it meant something.
Eventonomy’s private events were correctly hidden from listing pages, from search, and from the API. But if you had the direct link, you could read a private event’s full details regardless. Right instinct on three of four surfaces, and the fourth one quietly let everything through.
Learnomy had draft, pending, and archived courses reachable by anyone, logged in or not, if they had or guessed the URL. Courses in those states are supposed to be invisible outside the admin and the instructor building them.
And back to WB Gamification: our GDPR erasure process missed six tables when someone requested to be forgotten. One of those six holds a staff member’s written note about that person. So a customer who exercised their right to be forgotten was, in a real sense, not forgotten, and their own data export never even showed them that the note existed.
That one is hard to write about casually. It is exactly the kind of gap that only shows up when someone actually files the request, which is precisely why it sat there unnoticed.
The part I am genuinely pleased about: we deleted things instead of patching them
Somewhere in the middle of all this fixing, we made a call I think was the right one, repeatedly: if a setting does not actually do what it claims, remove it, do not quietly leave it there hoping nobody clicks it.
- Learnomy’s Enrollment Mode setting let you choose “Approval” instead of “Open.” Approval enrollment was never actually built. Both options did the exact same thing. We removed the setting rather than fake-build the feature under deadline pressure.
- Learnomy called its certificate anchor “blockchain anchoring.” It is not a blockchain. It is a tamper-evident hash registry, which is a real and useful thing on its own terms, just not the thing the label promised. We renamed it.
- WB Listora’s guest listing submission is gone entirely. Guests could never upload media in that flow, so it always dead-ended partway through the wizard. There was no version of “fix” that did not involve either building guest media upload from scratch or removing the entry point, and removing it was honest about where we actually are.
- WP Sell Services hid recurring services and a few wizard options that are not built yet, instead of showing them as if they work.
- Learnomy’s API for saving quiz conditional-logic rules now returns a 501 instead of silently accepting the save, because the quiz runtime does not evaluate those rules yet. Letting someone save a rule that will never run is worse than telling them it is not supported.
A visible control that does nothing is a lie the software tells the person paying for it. It is a small, specific kind of dishonesty, and it is one we can just not do. Deleting a broken feature is a better release note than shipping a new one that is half working, even though it feels worse to write in the moment.
One bug that was not even touching our own data
This is the one that actually rattled me. WB Gamification runs a cleanup job against Action Scheduler, the background task queue, to clear out old pending jobs. Our cleanup deleted every pending job older than the retention window with no check on who owned it. Action Scheduler is not ours. It is shared infrastructure that WooCommerce and a lot of other plugins run their own queued work through.
On a site running WooCommerce alongside WB Gamification, that cleanup could delete queued orders and subscription renewals that had nothing to do with gamification at all. A store owner chasing down a subscription that never renewed would have no reason in the world to suspect a points-and-badges plugin was the cause. That is the kind of bug that erodes trust in a way a visible one does not, because nobody would ever think to look there.
Rules that were already real, just invisible
Also in WB Gamification: seven of eight background features we found were already being enforced in the code, with no screen anywhere to configure them. The weekly points cap, for instance, was actively capping people’s points. There was just no field on any settings page where a site owner could see that cap existed, let alone change it.
Customers were living under rules nobody had shown them. Not malicious, not even really a bug in the traditional sense, just an oversight that had been sitting there since whoever built it forgot to wire up the interface.
This is not the first time
Earlier this month I wrote up a BuddyNext release that was almost entirely hardening. Most of the commits in it were invisible from the outside. At the time I thought of that as an unusual release, a one-off, the kind of thing you do once after a big feature push and then get back to building.
I do not think that any more. Two of these five releases follow exactly the same shape, and I suspect the ratio is just what it looks like to maintain products that handle other people’s money. The invisible work is not a phase you pass through on the way to the interesting work. It is most of the work.
What changes is whether you write it down. A hardening release with no release notes looks, from the outside, like a month where nothing happened. Nobody tweets about a refund that now rounds correctly in Kuwaiti dinar.
There is a version of this business where you only publish the features, and let the fixes go out silently in a point release nobody reads. It is a perfectly normal way to operate, and a lot of the industry does exactly that. But it means the only people who ever learn what was broken are the people it broke on, and they learn it the hard way, usually while trying to reconcile something.
I would rather spend the awkwardness up front. The cost of writing “this was wrong and here is how wrong” is a slightly worse-looking changelog. The cost of not writing it is that a customer finds it themselves and has to guess whether it is a bug, their own misconfiguration, or something they should be worried about.
What I actually think this means
The easy conclusion is “we are diligent, we found bugs, we fixed them, go us.” I do not think that is the real lesson, and I would rather write the uncomfortable version than the flattering one.
Features are what you plan. You sit down, you decide what to build, you build it, you ship it, and everyone can see it in the changelog. Correctness is not like that. You do not plan your way into correctness. You discover it, and mostly you discover it because someone used the thing in a way you did not personally try.
Almost every bug on this list needed a specific real situation to surface it. Someone selling tickets priced in yen. Someone with an order over a hundred attendees. Someone on a site running behind UTC instead of ahead of it, or ahead instead of behind. Someone submitting the same coupon from two tabs at once.
None of that shows up if the person who built the feature is the only one who ever tests it, because you do not naturally reach for the edge case that breaks your own assumptions.
That is not an excuse, to be clear. A lot of what is on this list shipped broken in the first place, and I am not going to write a victory lap pretending otherwise. The refund math should have handled zero-decimal and three-decimal currencies from day one. There is a whole ISO standard listing exactly which currencies use which decimal places, and we did not check our math against it before we shipped.
The timezone assumption should have been questioned the first time we wrote a query that touched “now.” These are not exotic edge cases in hindsight. They are ordinary ones we happened not to hit ourselves.
What I do think we got right this time is the response once we knew. Not papering over a broken setting with a tooltip explaining its limitations, just removing it. Not quietly patching the yen bug and moving on, but going back through every currency-touching money calculation in the product to look for the same class of mistake. Writing down, in the release notes, what we still have not finished.
And there is still an open one I want to say plainly rather than wait for someone else to find it. Eventonomy still displays some earnings figures to two decimal places on currencies that use three. The refund calculation itself is now correct. The number you see on screen, in certain reports and exports, can still be rounded in a way that does not match the currency’s actual precision. We know about it, it is in the release notes, and it is next.
If you want the broader argument for why we build this many products ourselves instead of stitching together other people’s plugins, I wrote about that in twelve products, one vendor, and about the shape of the whole thing in the complete platform I wish I had built on ten years ago. This week is sort of the flip side of both of those arguments.
Owning the whole stack means when we find a bug pattern in one product, our team can go check the others for the same shape of mistake, in the same week, instead of filing a report against a vendor and hoping. It also means when we get something wrong, there is nobody else to point at.
I would rather tell you that than pretend this was a features week. It was not. It was a week of finding out, again, that software only tells you the truth about itself once enough different people, in enough different situations, have actually used it.