<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Work | Webitops</title><link>https://webitops.com/work/</link><description>Three products we designed, built and still operate. Each case study covers the engineering decisions and what they cost, not the feature list.</description><generator>Hugo</generator><language>en-US</language><copyright>&#169; 2026 Webitops</copyright><lastBuildDate>Tue, 01 Sep 2026 00:00:00 +0100</lastBuildDate><atom:link href="https://webitops.com/work/index.xml" rel="self" type="application/rss+xml"/><item><title>Portflossio: a portfolio tracker that stores no balances</title><link>https://webitops.com/work/portflossio/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0100</pubDate><guid isPermaLink="true">https://webitops.com/work/portflossio/</guid><description>A Casablanca Stock Exchange portfolio tracker built on an append-only event ledger where every balance is derived rather than stored, kept fast by two-dimensional cache versioning. Laravel, Inertia, Vue.</description><content:encoded><![CDATA[<p><a href="https://portflossio.com" rel="noopener">Portflossio</a>
 tracks a private investor&rsquo;s holdings on the Casablanca
Stock Exchange: what they own, what it cost, what it is worth, and what they owe in capital gains
tax at the end of the year.</p>
<p>Most trackers in this category store your position. This one refuses to.</p>
<h2 id="the-constraint">The constraint</h2>
<p>Portfolio accounting has a property that makes the obvious design wrong: <strong>every number a user
cares about is a consequence of something else.</strong></p>
<p>Your position in a stock is a consequence of your trades. Your average cost is a consequence of
those trades and their fees. Your cash balance is a consequence of deposits, withdrawals,
dividends and the cash legs of every trade. Your realised gain is a consequence of the order the
sells happened in.</p>
<p>Store any of those, and you have created a second source of truth that can drift from the first.
Drift in a portfolio tracker is not cosmetic. A user who corrects a trade they entered wrong three
months ago, and whose average cost does not move to match, now has a tax report that is quietly
false.</p>
<h2 id="the-decision">The decision</h2>
<p>Only facts are stored. Everything else is computed on read.</p>
<p>There are two kinds of fact — capital events (deposit, withdrawal, dividend) and trade events
(a signed quantity, plus fees and taxes). The log is append-only: <strong>a mistake is corrected by
recording a new event, never by editing or deleting an old one.</strong> Positions, cash, weighted
average cost, realised and unrealised P&amp;L and total equity are all derived by replaying that log.</p>
<p>The invariants are stated up front and enforced in tests rather than left implicit:</p>
<ul>
<li>You cannot sell more than you hold.</li>
<li>Cash must reconcile exactly.</li>
<li>No number appears anywhere in the UI without a traceable origin in the event log.</li>
</ul>
<aside class="not-prose my-7 rounded-lg border-l-4 border-ink bg-surface px-5 py-4">
  <p class="text-xs font-semibold uppercase tracking-widest text-ink-muted">Why this is worth the trouble</p>
  <div class="mt-2 text-[0.95rem] leading-relaxed [&>p]:mt-2 [&>p:first-child]:mt-0"><p>Because it makes a whole class of bug impossible rather than unlikely. There is no stored balance
to drift, no migration that can leave two tables disagreeing, and no reconciliation job — because
there is nothing to reconcile against.</p>
</div>
</aside>

<p>Two things fall out of this design almost for free, which is usually the sign it was the right one:</p>
<ul>
<li><strong>Time travel.</strong> &ldquo;What did my portfolio look like last March?&rdquo; is not a feature requiring stored snapshots; it is the same computation with the event stream filtered by date.</li>
<li><strong>Inferred reinvestment.</strong> How much fresh money you put in, versus how much came from trading gains, is answerable from the log alone. Nothing has to be tagged at entry time — which matters, because users do not reliably tag anything.</li>
</ul>
<h2 id="what-it-cost">What it cost</h2>
<p>Recompute latency. Every page view replays the ledger. That is the bill for the guarantee, and it
is paid with caching — which then creates the actual hard problem, because a cache over derived
data has to be invalidated by <em>everything</em> that could change the derivation.</p>
<p>Rather than track which keys depend on which inputs, every cache key carries two version counters:
one for the user, one for the market.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-fallback" data-lang="fallback"><span class="line"><span class="cl">portfolio_summary_u{userId}_v{userVersion}_m{marketVersion}
</span></span></code></pre></div><p>Writing an event increments that user&rsquo;s version. A price update increments the global market
version. Either one invalidates every dependent entry — summary, tax report, chart data, asset
list, sentiment — atomically, with a single increment and no key enumeration. Stale entries are
never deleted; they simply become unreachable and expire on their own.</p>
<p>The property that makes it safe is that <strong>it is impossible to forget to invalidate something.</strong>
Adding a new derived value means adding a new cache key in the same namespace, and it inherits
correct invalidation by construction. Key-by-key busting has the opposite property: every new
derived value is a new opportunity to miss one.</p>
<aside class="not-prose my-7 rounded-lg border-l-4 border-brand bg-red-50 px-5 py-4">
  <p class="text-xs font-semibold uppercase tracking-widest text-ink-muted">The trade-off, stated plainly</p>
  <div class="mt-2 text-[0.95rem] leading-relaxed [&>p]:mt-2 [&>p:first-child]:mt-0"><p>This is deliberately coarse. A price update for a single stock invalidates cached data for every
user, including users who hold none of it. That is over-invalidation, and at a much larger scale
it would be the wrong call. At this scale it buys correctness for a cost measured in recomputation
that was already cheap.</p>
</div>
</aside>

<h2 id="the-rest-of-the-product">The rest of the product</h2>
<ul>
<li><strong>A market data pipeline</strong> that ingests and normalises exchange prices on a schedule, with retry and freshness handling, feeding both the portfolio valuations and a public price-history record.</li>
<li><strong>Capital gains reporting</strong> for the local regime, including loss carry-forward, exportable at year end.</li>
<li><strong>Bilingual English and French</strong> with translated URL slugs, hreflang alternates and a generated bilingual sitemap — the marketing and public market pages are server-rendered for crawlability while the application itself is a single-page app behind authentication.</li>
<li><strong>Authentication with optional TOTP two-factor</strong>, enforced email verification, and per-model ownership policies.</li>
<li><strong>A zero-signup demo</strong> seeded with plausible history against real prices, so the product can be evaluated without handing over a trade ledger. It deletes itself after 48 hours.</li>
</ul>
<div class="not-prose my-9 rounded-lg border border-rule bg-surface p-6">
  <p class="text-base font-semibold">Portflossio is live and free. The demo account needs no signup and disappears on its own — the fastest way to see the ledger model in practice.</p>
  <a href="https://portflossio.com" rel="noopener" class="mt-4 inline-block rounded-md bg-ink px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-ink">Visit portflossio.com &nearr;</a>
</div>

]]></content:encoded></item><item><title>Nissab-Zakat: a public data service built for agents as well as crawlers</title><link>https://webitops.com/work/nissab-zakat/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0100</pubDate><guid isPermaLink="true">https://webitops.com/work/nissab-zakat/</guid><description>A free zakat threshold reference in 11 languages, built so that AI agents can consume it as easily as browsers: a JSON-RPC MCP server, RFC 9727 API catalogue, llms.txt and a public JSON API over a cached market-data pipeline.</description><content:encoded><![CDATA[<p>The nisab is the wealth threshold above which zakat becomes due. It is defined in terms of gold
(85g) and silver (595g), which means answering &ldquo;what is it today?&rdquo; requires live metal prices, an
FX rate, and a decision about which of the two thresholds to use.</p>
<p><a href="https://nissab-zakat.com" rel="noopener">Nissab-Zakat</a>
 answers that question in 11 languages and roughly 150
currencies, for free, with no account. It is a public-interest reference rather than a business —
which is precisely what made the interesting design decision available.</p>
<h2 id="the-constraint">The constraint</h2>
<p>A site like this has two audiences, and only one of them was ever designed for.</p>
<p>The first is a person with a browser. The second is a machine: a search crawler, an LLM answering
a question about zakat, or an agent that needs today&rsquo;s threshold as a number it can compute with.
For that second audience the usual answer is &ldquo;scrape the HTML and hope&rdquo; — brittle for them, and
lossy for us, because a scraped page carries none of the provenance that makes a religious
finance figure trustworthy.</p>
<p>Since there was no commercial reason to hoard the data, we could design for the machine audience
directly.</p>
<h2 id="the-decision">The decision</h2>
<p>Treat machine consumers as a first-class surface with its own contract, and make that surface
<em>discoverable</em> rather than documented in a PDF nobody finds.</p>
<p>Concretely, the site ships:</p>
<ul>
<li><strong>An MCP server.</strong> Stateless JSON-RPC 2.0 over Streamable HTTP, negotiating several protocol revisions, exposing a tool that returns the current thresholds in any ISO-4217 currency. An agent can ask for today&rsquo;s nisab in Malaysian ringgit and get a typed answer with its provenance, rather than a paragraph of HTML.</li>
<li><strong><code>/.well-known/api-catalog</code></strong> — an RFC 9727 linkset, so a machine can find the API without being told where it is.</li>
<li><strong><code>/.well-known/mcp.json</code></strong> — a server card, so an MCP client can discover and connect on its own.</li>
<li><strong><code>Link</code> headers</strong> on responses advertising the sitemap, the catalogue and <code>llms.txt</code>, for consumers that never read the HTML body at all.</li>
<li><strong><code>llms.txt</code> and <code>llms-full.txt</code></strong>, and a <code>robots.txt</code> that names the major AI crawlers explicitly and declares content signals rather than leaving intent ambiguous.</li>
<li><strong>A public JSON API and an embeddable widget</strong>, so a mosque or charity site can show the live threshold without rebuilding any of this.</li>
</ul>
<aside class="not-prose my-7 rounded-lg border-l-4 border-ink bg-surface px-5 py-4">
  <p class="text-xs font-semibold uppercase tracking-widest text-ink-muted">The underlying idea</p>
  <div class="mt-2 text-[0.95rem] leading-relaxed [&>p]:mt-2 [&>p:first-child]:mt-0"><p>Discoverability is the feature. Publishing an API is easy; publishing an API that an agent can
<em>find on its own</em>, from nothing but the domain name, is what turns a website into something a
machine can actually use.</p>
</div>
</aside>

<h2 id="what-sits-underneath">What sits underneath</h2>
<p>None of that is worth anything if the numbers are wrong or stale, so the data path is deliberately
conservative:</p>
<ul>
<li><strong>Contracts and adapters.</strong> Metals pricing, FX and geo-to-currency each sit behind an interface with a swappable implementation and typed DTOs. Providers in this space change terms and go dark; none of them are allowed to leak into the domain.</li>
<li><strong>Cached with an explicit refresh path</strong>, on a schedule — FX twice daily, metals daily.</li>
<li><strong>Freshness is monitored, not assumed.</strong> A daily check alerts when data is older than it should be, and the service raises rather than silently rendering a zero. A zakat threshold that is quietly wrong is worse than one that is honestly unavailable.</li>
<li><strong>Bounded retention.</strong> Price history is pruned on a rolling window rather than growing without limit.</li>
</ul>
<h2 id="seo-and-localisation-as-engineering">SEO and localisation as engineering</h2>
<p>The other half of the work is being findable by the human audience, which for a reference site
means being genuinely multilingual rather than machine-translated:</p>
<ul>
<li><strong>11 locales</strong>, with <code>Accept-Language</code> q-weight negotiation and a <code>Vary</code> header so caching stays correct per language.</li>
<li><strong>Programmatic landing pages</strong> for the common currency queries, in every locale, each with structured data rather than a thin templated page.</li>
<li><strong>Server-rendered throughout</strong> — Livewire and Blade, no JavaScript framework. For a page whose job is to show one number quickly on a slow connection, that is the right trade.</li>
<li><strong>Cache headers with <code>stale-while-revalidate</code></strong>, a dependency-free inline SVG trend chart, and a service worker with an offline page.</li>
</ul>
<h2 id="what-it-cost">What it cost</h2>
<p>The agent-native surface is genuinely more work than a REST endpoint and a link to it: several
well-known documents to keep correct, a protocol to track as it revises, and a test suite that has
to cover discovery and version negotiation rather than just responses. The specifications are also
young enough that some of this will need revisiting.</p>
<p>It is also, so far, a bet. The audience of agents that discover a service this way is real but
early. We took it because the marginal cost on a project with no commercial pressure was low, and
because the alternative — being scraped badly — was the default outcome anyway.</p>
<div class="not-prose my-9 rounded-lg border border-rule bg-surface p-6">
  <p class="text-base font-semibold">Nissab-Zakat is free and needs no account. The API catalogue, MCP server card and llms.txt are all publicly reachable if you want to inspect the machine surface directly.</p>
  <a href="https://nissab-zakat.com" rel="noopener" class="mt-4 inline-block rounded-md bg-ink px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-ink">Visit nissab-zakat.com &nearr;</a>
</div>

]]></content:encoded></item><item><title>HoopoTrack: multi-tenant SaaS with metered, idempotent notifications</title><link>https://webitops.com/work/hoopotrack/</link><pubDate>Tue, 01 Sep 2026 00:00:00 +0100</pubDate><guid isPermaLink="true">https://webitops.com/work/hoopotrack/</guid><description>A multi-tenant SaaS for repair shops, built around a notification pipeline that is idempotent under retries and meters its own quota. Laravel, Inertia/Vue, Filament, WhatsApp Cloud API.</description><content:encoded><![CDATA[<p>Repair shops lose a surprising amount of their day to one question: <em>is my item ready yet?</em>
<a href="https://hoopotrack.com" rel="noopener">HoopoTrack</a>
 answers it without a phone call. Staff move a ticket through
a workflow they define; the customer follows a public tracking link with a live timeline, and a
screen in the waiting room shows the current queue.</p>
<p>That is the product in a sentence. The engineering interest is almost entirely in what happens
when a ticket changes status.</p>
<h2 id="the-constraint">The constraint</h2>
<p>A status change can fan out to email and WhatsApp. Both cost money, both are metered per plan,
and both are delivered by a queue worker that can be retried at any time — after a timeout, after
a deploy, after a crash mid-send.</p>
<p>That combination produces three failure modes that all matter commercially:</p>
<ul>
<li><strong>Double-send.</strong> The customer gets the same &ldquo;your repair is ready&rdquo; message twice. Cheap to cause, expensive in trust.</li>
<li><strong>Phantom quota burn.</strong> A send is counted against the shop&rsquo;s allowance but never actually goes out, or goes out twice and is counted once.</li>
<li><strong>Lost delivery on rollback.</strong> The job is dispatched, the surrounding transaction then rolls back, and the worker picks up a job referring to a status that no longer exists.</li>
</ul>
<p>The naive version — dispatch a job from a model observer and decrement a counter inside the job —
fails all three.</p>
<h2 id="the-decision">The decision</h2>
<p>Delivery is modelled as a row, not an action.</p>
<p>When a ticket status changes, a <code>NotificationDelivery</code> record is created <strong>inside</strong> the same
database transaction as the status change itself, with a unique constraint on the pair of
<code>(ticket status, channel)</code>. Creating the row <em>is</em> the claim to send. Two concurrent attempts race
for the same unique key; the loser catches the constraint violation, re-reads the winning row, and
does nothing.</p>
<p>The quota is reserved at the same moment, against the same transaction — before anything is
dispatched. If the reservation fails because the shop is out of allowance, the whole thing rolls
back together and no delivery row exists to be retried.</p>
<p>Only then is the send job queued, and it is queued <code>afterCommit</code>. A rollback therefore cannot
leave a job pointing at a status that was never persisted.</p>
<aside class="not-prose my-7 rounded-lg border-l-4 border-ink bg-surface px-5 py-4">
  <p class="text-xs font-semibold uppercase tracking-widest text-ink-muted">The shape of it</p>
  <div class="mt-2 text-[0.95rem] leading-relaxed [&>p]:mt-2 [&>p:first-child]:mt-0"><p>One transaction claims the right to send, reserves the budget for it, and records that both
happened. Dispatch is the last step and is conditional on the transaction surviving. Everything
downstream can then be retried freely, because the retry re-reads a row that already says what
was claimed.</p>
</div>
</aside>

<p>Failures are then split into two kinds, explicitly, as distinct exception types:</p>
<ul>
<li><strong>Transient</strong> — a timeout, a 5xx from the provider, a rate limit. Worth retrying; the delivery row stays pending and the reserved quota stays reserved.</li>
<li><strong>Terminal</strong> — an invalid phone number, a template rejected by the provider, a hard 4xx. Not worth retrying; the row is marked failed and the reservation is released.</li>
</ul>
<p>Collapsing these two into a generic &ldquo;it failed, retry it&rdquo; is how you get a queue that spends all
night retrying a phone number that will never be valid, while the shop&rsquo;s allowance stays locked up.</p>
<h2 id="what-it-cost">What it cost</h2>
<p>This is more machinery than &ldquo;send an email when the status changes&rdquo;, and it is worth being honest
about the bill:</p>
<ul>
<li><strong>An extra table and a unique index</strong> on the hot path of the most common write in the product.</li>
<li><strong>A reconciliation command.</strong> Rows can still get stuck — a worker dies between reserving and sending, and no exception is ever thrown to classify. A scheduled job sweeps for deliveries left pending beyond a threshold and re-queues or fails them. Idempotency is what makes that safe to run.</li>
<li><strong>Harder tests.</strong> The interesting cases are concurrent ones, so the suite includes tests that hammer the quota manager from parallel processes rather than asserting on a single happy path.</li>
</ul>
<p>The payoff is that the difficult questions have boring answers. <em>Can a customer get two messages?</em>
No, the unique constraint prevents it. <em>Can a shop be billed for a message that never sent?</em> No,
terminal failures release the reservation. <em>What happens if we deploy mid-send?</em> The job is retried
and finds the work already claimed.</p>
<h2 id="the-rest-of-the-surface">The rest of the surface</h2>
<p>The notification pipeline is the part worth writing about, but the product is a full SaaS and the
supporting work is most of the code:</p>
<ul>
<li><strong>Tenancy</strong> as a trait plus policies, with owner, staff, customer and platform-admin roles, and per-staff ability flags rather than coarse role checks.</li>
<li><strong>Quota and metering</strong> across tickets, workflows, clients, staff and each message channel, with monthly allowances, hard caps, and purchasable extra credits.</li>
<li><strong>Subscription lifecycle</strong> driven by webhook listeners — payment success and failure, refunds, entitlement sync on login — with in-app banners when a payment fails rather than a silent downgrade.</li>
<li><strong>Customer-facing surface hardening</strong>: rate-limited public routes, signed URLs for photos, optional PIN gates on tracking pages, and a dedicated test group for public endpoint protection.</li>
<li><strong>Trilingual, including right-to-left Arabic</strong> — around a thousand translation keys per locale across the app, plus locale-prefixed marketing routes.</li>
<li><strong>An audit log</strong> that captures tenant and user context, changed values, IP and user agent, surfaced in-app rather than left in a table nobody reads.</li>
</ul>
<div class="not-prose my-9 rounded-lg border border-rule bg-surface p-6">
  <p class="text-base font-semibold">HoopoTrack is live, with a free tier and public pricing. The tracking page and queue screen are both publicly reachable without an account.</p>
  <a href="https://hoopotrack.com" rel="noopener" class="mt-4 inline-block rounded-md bg-ink px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-ink">Visit hoopotrack.com &nearr;</a>
</div>

]]></content:encoded></item></channel></rss>