Personal project · Owner & Sole Engineer · Aug 2026 — present
HemaStore
A Telegram Mini App and an admin panel over one API
Shipped & running
What mattered
Checkout snapshots price and decrements access slots inside the same order transaction.
- Pricing is one pure function, shared by the API, the repricer and the admin preview: one rule, three callers.
- A game sells as kinds of access with their own slot limits, not as stock; slots decrement inside the order transaction.
- Money never becomes a JS number: amounts and ids are strings over the wire, BigInt in the client, formatted at the edge.
- Notifications are written in the same transaction as the order, then drained with claim, retry and reclaim.
System map
Context
My own shop, replatformed as a pnpm/TypeScript monorepo: a Telegram Mini App storefront, a Persian-RTL admin panel, a Fastify API, two grammY bots and one Postgres. Owner and sole engineer.
The schema follows the way the shop sells access. A game sells as several kinds of access — Xbox Home/Non-Home, PSN Capacity, full account, code — rather than as stock, each with its own price and its own slot limit.
The Mini App
Telegram hands the app a signed initData blob and nothing else. Every request
goes through one fetchJson wrapper that attaches it:
headers: {
...(opts.body != null && !isFormData ? { "Content-Type": "application/json" } : {}),
"X-Telegram-Init-Data": getInitData(),
}
Both conditions in that spread are bugs I hit: a FormData receipt upload must
not get an explicit content type or the browser cannot attach the multipart
boundary, and a bodyless POST must not get application/json either, because
Fastify 500s parsing an empty body.
Money never becomes a JS number. Toman amounts run into the millions and
ids are large; the API serialises both as strings, and the wrapper passes the
parsed JSON through untouched rather than mapping numeric-looking fields. Values
are BigInt in the client and formatted at the edge.
RTL is where most of the fiddly work was. formatToman groups thousands with
the Arabic thousands separator (U+066C) and converts to Persian digits, but the
Money component renders inside dir="ltr": a grouped number in an RTL flow
has its separators pushed to the wrong side otherwise.
Admin inputs need the inverse, so parseTomanInput accepts Persian digits, both
separators, spaces and zero-width non-joiners and normalises back to BigInt.
The channel-post formatter deliberately uses a different function with Latin
digits and commas, because those posts have always been written that way.
The variant picker is where the capacity model becomes something a customer can
choose between: a role="radiogroup" of rows, one per kind, with labels and
explainers held in lookup tables keyed by kind rather than scattered through
JSX.
The cart is localStorage only — no server session for an anonymous browser.
useCart listens for the native storage event and a custom
hema-cart-change event, because storage fires in every other tab but never
the one that made the change.
The admin panel
Everything is inline-editable, which put the difficulty in commit semantics and layout stability rather than in the forms themselves.
NumberInput displays 1,000,000 while state holds "1000000": grouping is
inserted as you type and the caret restored beside the digit it was next to, so
editing the middle of a long number does not throw the cursor to the end. But
the commit rule matters more than the formatting:
typing only updates local state, and
onCommitruns on Enter or blur, never on a keystroke — an inline table editor must not fire a PATCH halfway through a number.
Enter and Escape blur the field themselves, so a skipBlurRef swallows the
blur they cause; otherwise Enter double-PATCHes and Escape's cancel is
immediately resurrected.
Two components keep the columns from moving. SaveIndicator keeps a
fixed-width slot even when idle, so save feedback cannot resize its cell.
DataTable uses table-layout: fixed with a generated <colgroup>, so pills,
spinners and errors do not reflow neighbouring columns.
The pricing panel shows how each price was chosen. An automatic price is
min(margin over buying cost, share of the US store price); the ceiling exists
because a customer can always buy the game themselves with a gift card.
The engine returns boundBy, so the panel explains margin and ceiling
differently and the admin sees why a number is what it is. The same pure
function in packages/shared backs the API, the repricer job and this preview,
so all three agree by construction.
Behind both
Checkout snapshots prices into OrderItem so a reprice mid-checkout cannot
change what was agreed. Capacity slots decrement inside the order transaction,
so two concurrent checkouts cannot claim the same seat. Promo caps are enforced
by a guarded UPDATE where the affected-row count is the check:
UPDATE "PromoCode" SET "usedCount" = "usedCount" + 1
WHERE "id" = $1 AND ("maxUses" IS NULL OR "usedCount" < "maxUses")
And notifications are written to an outbox table in the same transaction as
the order, then drained by the bots with pending → sending → sent, an
attempt counter, and a claimedAt stamp so a row stranded by a worker that died
mid-send is reclaimed rather than stuck forever.
Result
Running as my live shop since August 2026. The Mini App, admin panel and both bots are the ones in daily use, not portfolio rebuilds. It is still single-author and single-tenant, so multi-operator admin workflows are not the claim here.
Screens


