Engineering Case Studies
Six systems,
one way of working
Two AI products built end to end with guardrails and audit trails, and four production platforms where the hard part was transactional correctness under load, regulatory arithmetic, or replacing a monolith without a big-bang cutover.
On naming. Systems are described by what they do, and my role in each is stated precisely — which part is mine, and which part a colleague wrote. Where a claim is weaker than it sounds, I say so rather than let an interviewer find it.
AI CRM Copilot — Next.js + LLM
A working CRM MVP built solo against a real "Lead AI Software Engineer" take-home brief: companies / contacts / leads / pipeline, a persistent Postgres backend, an AI sales copilot, a LINE Official Account integration, and a full audit trail — plus the BA → SA → PM → QA documentation trail behind it, not just the code.
Stack & delivery
Next.js 14 (App Router), TypeScript strict, Zod on every API boundary, Prisma over
PostgreSQL. Auth is a signed HS256 session cookie — deliberately not SSO, called out as
an explicit scope cut rather than left unstated. CI runs typecheck, build and Playwright
against a real Postgres service container, plus a security job (npm audit, CodeQL SAST,
gitleaks secret scanning). Deployed on Vercel against managed Postgres, auto-deploying
from main.
What "it works" means, written down before it was built
The copilot ships with a committed skill and evaluation spec —
skills/crm-copilot/SKILL.md — defining its input/output
contract, its hard guardrails, and five named evaluation cases. Three of
the five are automated today; the other two exist as branches in the deterministic mock
adapter and are still checked by hand. I would rather publish that split than imply a
harness I have not finished. The deployed instance runs the copilot in
deterministic mock mode so the demo is reproducible and costs nothing;
the real provider path is the same code behind one environment flag, and I can switch a
live key on for a walkthrough.
CI is not a lint job. Every push runs typecheck, a production build, and the full
Playwright suite against a real PostgreSQL service container seeded through
prisma db push — the E2E tests run against
next start, not the dev server, so they exercise the build that
actually ships. A separate security job runs dependency audit, CodeQL SAST and gitleaks
secret scanning.
The AI layer is four defences, not one prompt
- Prompt-injection filtering before the model sees anything. Untrusted inbound text (LINE messages, lead notes) passes through a guard that neutralises ten injection patterns, paired with a hardened system prompt. The threat model is written down and mapped to OWASP LLM Top 10 — LLM01 injection, LLM02 insecure output handling, LLM06 sensitive-information disclosure.
- PII never leaves the process intact. Emails and phone numbers are masked and names reduced to a first name before any payload goes to a provider; the audit logger applies the same redaction to every row it writes.
- Every model response is validated against a schema. A Zod contract pins the shape — score is a 0–100 integer, summary is at most three items, the drafted reply is capped at 200 characters. A schema-invalid response is not persisted; it degrades to the fallback instead.
- A deterministic engine takes over on failure. Both the LLM engine and
a heuristic engine implement one
AiCopilotEngineinterface, so the service layer and UI never branch on which one answered. Provider timeout, 5xx, 429 or schema failure all route to the heuristic path. A mock mode makes the whole thing demoable without an API key, and an E2E suite forces a provider failure to prove the fallback actually fires.
The AI cannot act on its own
Copilot output is always written as a draft. Approving or discarding it are separate, audited routes, and the human approver is the actor of record — discarding writes an audit row and nothing else. The copilot cannot write to the database or send a LINE message without that confirmation.
Retrieval: two ranking systems that do not share a scale
Lead context is retrieved by hybrid search over pgvector — dense vector similarity and full-text, run separately and then fused. The fusion is Reciprocal Rank Fusion rather than a weighted sum of scores, because a cosine distance and a text-search rank are not on the same scale and normalising them (min-max, z-score) makes the weights a tuning exercise that silently re-tunes itself whenever the data distribution moves. RRF only reads positions, so it needs no normalisation and no weights to drift.
Two details I would rather state than let someone find. The migration that adds the extension and the index ships with a paired rollback script, hand-run and reviewed in the same change — Prisma has no down-migrations, and a migration you cannot reverse is not finished. And the first version had an embedding-staleness hole: an edited record kept its old vector, so search answered from text that no longer existed. Closing it came with a test that fails if the owner filter ever falls out of the raw vector SQL, because that filter is the only thing standing between hybrid search and cross-tenant reads.
The webhook is the front door, so it is treated like one
Inbound LINE messages arrive at a public endpoint, which makes signature verification a
correctness problem, not a checkbox. The handler reads the raw body before any
JSON parsing — parsing first would change the bytes the signature covers —
computes HMAC-SHA256 over it, and compares with timingSafeEqual
rather than ===, so the comparison cannot leak a byte at a time.
Redelivery is handled at the database rather than in memory: a unique constraint on the
provider event id makes replay a no-op, which survives a restart and a second instance in
a way an in-process cache does not. Four E2E tests cover that surface specifically —
missing signature, wrong signature, replayed event, malformed body.
How it was built — and what the agent pipeline did not catch
I built it by driving Claude Code through a role-split pipeline — BA, SA, PM, DEV, QA — and kept the artefacts each step produced. I would not present that split as the reason the result is any good. The published work on role-prompted multi-agent pipelines does not support that claim, and this project is a case in point: the pipeline finished, every check was green, and three real defects were still sitting in the code. What found them was reading actual behaviour instead of trusting a 200.
- A password-hash leak — a natural-looking Prisma
include: { owner: true }was returning full User rows, hash included, in every lead API response. Caught by reading a raw response body instead of trusting the 200. Fixed with an enforced safe-select pattern everywhere a User relation loads. - An RBAC bypass — the leads list endpoint checked "is there a
session," not "does this role scope to their own leads," so a Sales Rep could pass
?ownerId=<anyone>and read team-wide data. Fixed in the service layer, and then fixed structurally: the ownership check was pulled out into one guard every consumer calls, because a check that lives inline is a check the next endpoint forgets. Six adversarial E2E tests now hold that line permanently — cross-tenant lead access, list-scoping bypass, and illegal terminal-state transitions — so the regression cannot come back quietly. - A test hook that would have silently gone dark in production — gated
on
NODE_ENV !== 'production', it could never fire once E2E ran against a prod build. Replaced with an explicit flag instead of overloading build mode as a proxy for "is this a test."
Hermes — Six-Provider AI Assistant
A self-hosted chat-ops assistant I designed and built solo, run as a long-lived service (Bun + TypeScript strict). The interesting engineering is not "call an LLM" — it is routing, cost control, failure handling and blast-radius control across six providers, plus an agentic tool layer exposed over MCP.
Two-dimensional failover, not a single API call
A router spans Gemini, Groq, DeepSeek, OpenRouter, OpenCode and NVIDIA NIM. Per-mode chains are flattened into a linear (provider × model) plan, then each route walks its own key pool — so a failure retries the next key, then the next model, then the next provider. Each provider has a multi-key pool with automatic cooldown (configurable, defaulting to ~1h on a rate-limit error and 24h on an invalid key), and failures are classified by matching the provider's error text rather than status alone, because free tiers routinely return 200-with-an-error. Keys are only ever logged as a 4-character head/tail tag. A free-tier-only enforcement mode plus per-provider success/failure/fallback counters keep cost visible and bounded.
Agentic, and cheap about it
Intent detection runs in two phases: a synchronous, zero-LLM pass extracts emails, IPs, dates and bilingual Thai/English action keywords, and short-circuits straight to "chat" for the majority of messages, so most turns cost no tokens at all. Only ambiguous messages reach phase two — one small latency-first call that decides intent given the already extracted entities. Every failure path (no route available, parse error, unknown intent, missing field) falls closed to plain chat: a misparse degrades to conversation, never to a wrong privileged action. From there a bounded ReAct loop (THOUGHT / ACTION / OBSERVATION, hard step cap) drives the tool set.
The tool layer is exposed twice, from one registry
The same tool declarations power both the in-process agent and a standalone MCP server over stdio, with a translation layer converting the internal declaration format into MCP JSON Schema at list time — one implementation, two surfaces. The MCP entry point resolves its own environment relative to its module path rather than the working directory, because an MCP host spawns it with a cwd that differs between native and containerised deployment.
Guardrails on the tools, not just the prompt
The riskiest tool reaches a production database. It is not a read-only path —
INSERT and UPDATE are permitted under
write-shape constraints — and I would rather say that than let the word "read" do work the
code does not do. It sits behind a deny-by-default SQL gate: a
statement-type allowlist plus a forbidden-pattern list that goes well past the
obvious DROP/DELETE — also
CALL/EXECUTE/PREPARE
(dynamic SQL that would route around the allowlist), FOR UPDATE and
LOCK IN SHARE MODE (row locks that would hang real users),
SET SQL_SAFE_UPDATES=0, and the file and command primitives
INTO OUTFILE, LOAD_FILE(),
LOAD XML and the sys_exec UDFs. An
UPDATE must carry both a WHERE and a
LIMIT ≤ 10; multi-row INSERT is bounded by a
scanner that counts real value groups instead of trusting a regex.
The interesting part is what a red-team pass found after I thought the gate was
done. A regex allowlist inspects text; MySQL inspects tokens, and the two disagree.
UPDATE/**/users splits the keyword past a
boundary. /*!SELECT LOAD_FILE(…)*/ is a
comment to a scanner and executable SQL to the server. A WITH … UPDATE
CTE passes a first-keyword check while performing a write. A
LIMIT 1 inside a subquery satisfies a naive
"does it have a LIMIT" test while the outer statement rewrites a hundred thousand rows.
Each of those got through. The fix was to stop pattern-matching raw text: strip comments
and string literals into a normalised form first, resolve the CTE down to its real verb,
and evaluate WHERE and LIMIT against a
top-level skeleton rather than the whole string. That red-team pass was mine and by hand;
the standing suite lives in the Python port below, not in the original.
I ported the guard to Python to test it against a larger case set than I had written by hand:
legitimate queries and attacks in comparable numbers, with a guard test that fails if the
accept set ever drops below three quarters of the attack set, because a gate that rejects
everything is not a gate. Writing those tests surfaced a bypass that reading the code had
not: EXPLAIN ANALYZE is the one spelling of an allowed verb that
executes the statement it claims only to describe. The port ended up carrying one rule more
than the original — that extra rule is the finding. The port itself was largely
AI-written under my direction; the original, the threat model and the rules are mine.
Every write carries a per-user sliding-window rate limit and an append-only audit row, and
the caller identity is a required parameter with no default. That last
detail is the one I would talk an interviewer through: the original signature defaulted the
actor to "system" and skipped the rate limit for that actor, so every
call site that simply forgot the argument wrote to production unthrottled and un-attributed
— three of the four did. A default value is not a convenience when the parameter is an
identity; it is a way to lose the audit trail quietly. Connections run over an on-demand SSH
tunnel with small per-database pools.
One guard, counted — the same lesson twice
The HTTP surface had twelve routes with a hand-copied session check and four without one. The four without included the endpoint that executes SQL against production. Nobody removed a guard; the guard was simply never added, because a check that lives inline is a check the next route forgets — the identical failure I had already hit and fixed in the CRM copilot's ownership logic. The fix is the same shape both times: extract one guard, route every caller through it, and then count the callers rather than assert coverage. Thirteen of nineteen branches are now gated by that single helper and zero hand-written checks remain; the six that are open are open deliberately — health, the login views, and the auth endpoints themselves, which cannot require a session to create one.
The one-click login on that surface turned out to be the real hole. It admitted any
request whose Host header started with 127.0.0.1
— a header the caller writes. Any page in a browser on that machine could mint a session
and then drive the SQL endpoint; CORS would block reading the response, but the write had
already happened. It now requires an Origin matching the login page,
which is a header the browser sets and a hostile page cannot forge.
Still open, and I would say so in an interview. This assistant ingests scraped RSS and job-board content into the same context as an agent holding a production-database tool. It has no prompt-injection defence — no provenance marking on untrusted text, no instruction hierarchy. The SQL gate and the capability checks are what bound the blast radius today; that is mitigation, not a defence, and closing it properly is the next piece of work rather than something I have shipped.
Memory that survives a restart — and forgets what it should
A lightweight RAG-style memory layer with lexical retrieval (token-overlap scoring, no embeddings — "RAG-lite" is the honest label), populated automatically from conversation rather than curated by hand. The write side has a dedup gate that rejects a new fact above a similarity threshold, and both the extraction prompt and a client-side filter drop anything password-, token- or API-key-shaped before it can be persisted. Chat logs are classified and retained on different windows per class, scrubbed of secret-shaped strings, and purged on a timer.
What it actually does
Design note worth the interview question. Job matching is deliberately rule-based, not LLM-scored: a three-tier location classifier that distinguishes Thailand-remote from foreign-locked-remote, a title-level technology blocklist with an escape hatch when a known stack also appears, weighted stack points and a salary floor. Deterministic, auditable, and free to run — an LLM would have been slower, costlier and harder to explain when it got one wrong.
Regulatory Formulation Engine — Three Generations
An in-house system for a contract manufacturer's R&D lab: it catalogues raw materials with their chemical (INCI) composition, lets chemists build product formulas from them, and derives the finished product's regulatory ingredient declaration — the list that ends up on the label and on a government filing. I built all three generations of it, and the story of why v3 exists is the interesting part.
v1 — the modelling decision that made it possible
The first version separated a purchased raw material from its chemical declaration: a material has many constituent rows, each with its percentage inside that material, its function, its CAS number, its regulatory limit, and a flag for whether it appears on the printed declaration. That one decision is what makes automatic label generation possible at all. It also shipped an append-only audit trail and a reverse lookup — "which materials contain substance X" — which is the query a lab needs the day a substance gets restricted. It was also a no-framework PHP app in which the core derivation was copy-pasted into five files.
v2 — a regulatory document factory
A CodeIgniter 3 rewrite that turned the registry into a document generator: one formula renders into six jurisdiction-specific dossiers (PDF and Excel, in domestic, full and export-filing variants) under three different legal manufacturing entities. Controlled-document conventions are baked into the PDF layer rather than bolted on — every page header carries the document number, revision number and effective date that an ISO/GMP audit expects, and each export view hand-paginates into a fixed row count — 20 for the domestic and full dossiers, 15 for the China filing — so a reprint for an audit paginates identically every time. The debt, however, got worse: the regulatory calculation now existed in seven copies, running float arithmetic inside the view layer, with an N+1 query per ingredient. Seven copies that all have to agree for a filing to be defensible is not a maintenance problem; it is a correctness problem.
v3 — one calculation, exact, enforced
- The regulatory calculation became a single deep function. It expands every formula line into its material's constituents, merges duplicates arising from different materials, sorts by concentration then alphabetically (the labelling order), takes the highest permitted maximum among the contributing sources, and flags any row whose derived concentration exceeds the permitted maximum. Seven float copies collapsed into one.
- Exact decimal, enforced rather than trusted. Columns are
DECIMAL(30,15), the server computes only through arbitrary-precision decimal arithmetic at scale 15, the browser mirrors the same library, and a validator rejects any value with more integer or fractional digits than the column can hold — so the client can never submit a number the database would silently truncate. For a figure that ends up on a government filing, float drift is a correctness bug, not a rounding nicety. - Composition integrity is a transactional invariant. Both save paths group duplicate rows, sum them exactly, and require the total to equal exactly 100% — rolling back otherwise. Not a UI warning; a constraint.
- Child rows are diff-synced inside one transaction — update the submitted ids, insert the new ones, delete exactly the ones no longer present — instead of the usual delete-all-then-reinsert. That preserves each line's original created-by and created-date across edits, and provenance is the entire point of an R&D record.
- Password migration with no flag day. Login verifies against the modern hash, transparently re-hashes when the cost factor is stale, and otherwise falls back to a constant-time comparison against the legacy digest and immediately upgrades that user — so a decade of legacy password rows migrate themselves as people sign in.
- One auth gate, two response shapes. The guard answers an XHR with a 401 JSON envelope but redirects a browser navigation to the login page, so the SPA layer and the server-rendered pages share one implementation instead of drifting apart.
Built for a factory floor, not a data centre
Every front-end dependency is vendored — no CDN reference anywhere — and PDF/Excel generation was moved off the server into the browser so an old on-premise machine needs no server-side document libraries. The Thai typeface is embedded into the PDF generator's virtual filesystem so Thai text renders in generated documents. Packaging is a script that bundles the app plus a database dump and emits an installer: the release medium is a USB drive walked to an air-gapped server. Controlled-document metadata — document number, revision, effective date, each entity's legal name — moved out of hardcoded template branches into configuration, so a revision bump is a config change rather than an edit across six renderers.
What I would fix. The legacy password fallback is a deliberate but real compatibility risk that should have had an end date attached to it, and there are framework-level hardening settings I would review first. The self-migrating hash is good engineering; it does not make the app hardened overall, and I would not claim that it does.
Strangler-Fig Migration to Nuxt 4
Replacing a large legacy PHP internal operations portal with a Nuxt 4 application that takes over routes one at a time — sharing the same URL space, the same session and the same databases as the app it is replacing, with no big-bang cutover and no user-visible move. I am the sole author of the Nuxt 4 side — every domain module, every API route, and the Playwright E2E harness that covers them, which I wrote and own outright.
One checked-in registry decides which app serves a URL
A route registry — JSON, with its own JSON Schema alongside it — lists exactly which URL
patterns belong to the new app. A build script compiles it into an nginx map whose
default is legacy, so any URL nobody has listed silently
keeps working on the old system. Migrating a route is a one-line registry edit; rolling it
back is deleting that line. No code change, no DNS change, no coordinated release. A small
manifest pins exactly which files must be mirrored into the legacy repository, and CI
pushes them across the repo boundary only when those files actually change.
The registry is also the test oracle
The usual way a strangler-fig migration fails is that the routing table and reality drift apart. So the registry generates tests: one Playwright case per entry, run against the live environment, asserting that the page loads and its API returns something that is not a 4xx or 5xx. That catches post-deploy breakage — a missing table, a 403 — that no build-time check can see.
Three architecture rules that are scripts, not conventions
- No data fetching outside the service layer. A gate walks every application file and fails the build if a fetch call appears anywhere except a module's declared client service — mechanically preventing data access from leaking into pages and components.
- Every module has the same skeleton. A second gate asserts the six required subdirectories and a named client service per module; a scaffolding script generates conforming modules, so the gate is the path of least resistance rather than an obstacle.
- Per-layer file-size budgets. Pages 200 lines, module components 500, composables 400, client services 300. A single ship command chains readiness check → deploy check → E2E → production build and refuses to push if any stage fails.
Two apps, one identity
Session bridging is where this kind of migration usually gets ugly. An 8-hour signed token is issued into an httpOnly cookie whose Secure flag is derived from the forwarding protocol and whose domain is computed so local development gets no domain attribute at all — the detail that makes one cookie work across both hosts. Dedicated endpoints hand identity in both directions, and the hand-off returns an escaped top-level redirect specifically so it cannot be captured by an iframe or the SPA router. The internal client that talks to the legacy app is hand-rolled with an explicit timeout and a bounded redirect follower, and it relaxes TLS verification for exactly one condition — localhost in development — rather than globally.
Authorization is one guard, and it fails closed
A single composed guard resolves auth → session → permission → capabilities against a module map. An unknown module key throws 403 rather than defaulting to allow, and a missing capability flag reads as false. Nearly every endpoint calls that guard; the exceptions are auth-flow endpoints, one scheduled job that checks a shared secret header instead (with an explicit note rejecting forwarded-IP headers as spoofable), and two deliberately public endpoints that require a per-record access code. None is simply unguarded — I walked the whole list rather than assume it.
Details that only matter in production
Four separately-credentialed database pools keyed by role and target, so a hot reload or a credential change creates a new pool instead of silently reusing a stale one; each pool sets its session timezone on every new physical connection; a missing credential throws an error naming the exact variable to set rather than failing later with an opaque auth error. Reads still belonging to the old system go through a pool documented as read-only. And because this is a parity migration, "the new page looks like the old page" is a CI assertion: pixel snapshots against production baselines with animations disabled and explicit masks over volatile cells.
Swapping the component library underneath a live migration
The app shipped on Vuetify and is moving to Nuxt UI 4 on Tailwind 4. The released branch is still all Vuetify; the working branch has none — Nuxt UI in its place, across the whole component tree, in two commits. It was safe to attempt only because the component tree was already covered by the E2E tests the route registry generates and by pixel baselines taken against production, so a library swap could be shown not to change what any page renders. What remains is the merge, not the rewrite.
A capability gate on the agent that can deploy this
The same AI coding agents that write in this repository can also reach the command that ships it to production, so the deploy path is gated on a capability token rather than on the agent's judgement. A committed skill file defines the rule: the deploy workflow runs only when the human's message contains one exact authorization token. It explicitly refuses to treat natural language as consent — "deploy to prod", "ship prod", "ขึ้น prod" are all listed as phrases that must not trigger a push — and it forbids the agent from volunteering the token, so the agent cannot talk itself through its own gate. Without the token the agent may explain the steps or run the local gate, never the push.
This is the same idea as the human-approval gate in the CRM copilot, applied one level up: the model is not trusted to decide when an irreversible action is authorised. The skill file is the written rule the agent follows; the thing that enforces it is a PreToolUse hook that inspects the command before it runs and blocks a push to an auto-deploy branch outright — with a refusal written to anticipate being argued around — so the gate holds whether or not the model cooperates. The deploy itself is then a fixed six-step sequence — local gate, push, CI build, rsync, bridge sync — with pre-flight checks that must pass first.
Machine-readable standards. A committed, non-secret environment file declares the stack, the required guards, the gate commands and the SQL/validation policy — so AI coding tools read the project's rules before generating a line of code, instead of inferring them from whatever file they happened to open.
One PHP Monolith, Three Products
The system the migration above is peeling apart: a single PHP codebase serving a public multilingual marketing site, the CMS behind it, and a manufacturing ERP — QC lab testing, stability studies, raw-material lifecycle, production planning, regulatory submissions. A large codebase on a CodeIgniter 3 fork hand-patched to run on PHP 8.3.
Thai-language SEO done at the routing layer
The URI whitelist is extended with the Thai Unicode block so roughly forty fully Thai-language slugs resolve as first-class routes rather than query strings. Historic slug changes are preserved as permanent redirects at the edge rather than in application code, so link equity survives a rename. SEO metadata is a CMS-owned data structure — title, description, keywords and canonical URL joined per page and per language — feeding the OpenGraph block and two JSON-LD blocks, with six XML sitemaps generated live from the same models and declared in robots.txt.
Permissions recomputed, never trusted
Authorization runs as one pipeline: permission rows from the database → business rules →
a lazily cached per-request capability set → explicit can() /
require_can() checks. The rule-computation step explicitly strips
the legacy permission keys out of the session on every request, so a stale or tampered
session cannot grant access — permissions are derived from the database, never read back
from the client.
A Vue SPA layer inside server-rendered PHP, with no build step
The admin and ERP screens are not PHP forms. Server-rendered views mount Vue 2
applications loaded straight from a CDN with no bundler, no dependency install and no build
artifact to deploy. The base controller selects a header per screen mode, and the view
mounts new Vue({ vuetify: new Vuetify() }) over markup PHP has
already rendered. The largest single screen is a form whose every field derives its
:disabled and :outlined state from the same
server-computed capability set that authorises the request, so the client cannot offer an
editable field the backend would reject. Another is a submission workbench whose data-table
column set is filtered in a computed property, backed by several JSON endpoints on the same
monolith.
The trade-off is deliberate and worth stating. No build step means no toolchain to keep alive across a decade of PHP upgrades, and no deploy step beyond copying a file — but it also means the dependency is a URL. The newer entry point pins an exact version behind a subresource-integrity hash; the oldest still resolves a floating major tag, which is a supply-chain exposure I would close by vendoring it, as the stylesheet already is.
A 504 that was not the query count
A monitoring list endpoint was timing out. The obvious cause was an N+1 inside a loop, and that was real — replaced with a batch fetch that collects all keys up front and runs three grouped queries into in-memory maps. But the actual cause of the 504s was PHP's file-based session lock serialising concurrent search requests. Releasing the session lock at the top of the JSON endpoints is what fixed it; the N+1 collapse is what made it fast. Both, in one change, at five call sites.
Multi-tenancy by construction, not by convention
Five separate database connections are loaded from configuration and each product's writes stay in its own schema, which makes a cross-database join impossible by construction rather than by code review. Underneath every model sits a shared services layer: an upload pipeline that re-encodes to WebP with aspect-preserving resampling and slugified filenames, Thai/Buddhist-era date handling, a validator family, and outbound notification.
The fork is deliberate. The vendored framework implements the magic accessors that keep CodeIgniter 3's dynamic-property idiom legal on PHP 8.3, instead of sprinkling an attribute escape hatch across the codebase or pinning to an end-of-life release. It is a compatibility layer with a written-down rule, not an accidental copy.
POS Platform & Oracle ERP Integration
The point-of-sale and inventory platform behind a 90+ branch retail network, and the integration between it and a corporate Oracle ERP. I am its technical owner and principal developer — the person the codebase routes back to, and the author of most of it. Where my contribution is narrower than the system, I say which part.
Correctness work that is not obvious from the outside
- Repricing that cannot strand an open cart. Inventory repriced from a spot rate short-circuits when nothing changed, then runs one transaction that updates both the product rows and any cart lines already holding those products — so a customer mid-checkout cannot be charged a stale price — and finishes with a read-back that verifies the columns actually landed.
- Daily revenue aggregation made idempotent. Branch revenue rollups roll back an order's prior contribution before re-accumulating it, so a re-run corrects rather than double-counts, and all of the money math is exact-decimal addition rather than floats.
- Branch-scoped real-time without per-branch infrastructure. Push events are addressed to a channel named by hashing the branch identifier, so a message reaches exactly one branch's screens while the relay needs no knowledge of branch topology and the identifier never appears on the wire. The same trick, keyed on a hashed user id, drives forced remote sign-out.
Oracle ERP, both directions
Outbound, the POS pushes AR Invoice, AR Cash Receipt and Credit Note documents alongside purchase-order, receive and transfer operations, dispatched through one named-operation layer that resolves the correct ERP organization per branch, with unknown operation names and malformed payloads failing loudly to an alerting channel rather than silently. Inbound, scheduled workers pull item-level inventory deltas and reconcile them into the local schema. That importer is a colleague’s work, not mine — I integrated against its contract and had to understand it to keep branch stock right, which is why the two design points below are described rather than claimed:
- An overlap window instead of an exact high-water mark. Each pull asks for changes since "now minus ten minutes" for the regular pass and "now minus one hour" for the catch-up pass. Deliberately re-reading a known-overlapping window is the standard defence against clock skew and mid-transaction commits, and it is applied consistently across every fetch mode.
- A status mapping with a real second dimension. Translating ERP lot status into the local stock model is not a lookup table — the warehouse locator is a second dimension, not a tiebreaker, so the same ERP status resolves to different local states depending on where the item physically sits, and forks again for head-office locations. That mapping only exists because someone reconciled two systems' vocabularies by hand. It is the single artifact I would most want to walk an interviewer through.
- Every call is journaled for reconciliation. Outbound payload and raw response are written to a log table and appended to a date-rolled file log and pushed as a success/failure notification, with alerting suppressed outside production. Long imports are detached so an HTTP trigger returns immediately instead of holding a ten-minute request open.
- Deterministic identifiers for exploded lots. When one inbound row represents N physical units, each unit's barcode is derived deterministically from a keyed hash rather than generated — so a re-run produces identical identifiers instead of duplicates. That is a real idempotency property.
A partner sales channel: spec, auth, and a clean shutdown
A new sales channel needed to transact against both the POS and the customer-facing gateway. I owned the API contract: the module-level specification documents, the endpoint surface, and the service-to-service authentication — a token client that acquires, caches, decodes the token's own expiry claim and refreshes pre-emptively rather than waiting for a 401. The channel became a first-class dimension across the domain: an order channel discriminator, per-channel product publish state, per-channel payment-callback routing, per-channel exclusion from branch sync, and employee purchase quota rules. One explicit trade-off worth discussing: at the boundary we chose to trust the partner's payload rather than re-map it on receipt, which bought integration speed at the cost of a validation seam.
The programme was then cancelled. Taking a cross-cutting integration back out of two live production systems — touching cart, catalog, customer, payment and scheduled jobs in the gateway alone — without downtime is a less glamorous skill than building it, and a more useful one.
Documents, tax, and consent
A dedicated renderer produces the Thai full tax invoice (ใบกำกับภาษี): taxpayer identification, head-office versus branch designation, and structurally separate VAT and non-VAT layouts driven by a per-company rate, with pre-VAT / VAT / post-VAT summaries and matching credit-note reports. At checkout, a PDPA consent gate blocks the sale until consent is captured, surfaced on a paired customer-facing display that also handles the payment QR and customer-initiated cancellation.
Guards a reviewer would not expect in a PHP MVC app
Staff sign-in validates identity-provider tokens by fetching the provider's key set, converting each certificate in the chain to a public key, keying them by identifier and caching the result to disk with a 24-hour TTL — so the login path does not hit the provider on every sign-in. Outbound integrations are signed with an HMAC over the token and raw request body. A store-hours kill switch reads open and close times from configuration, applies a different closing time for smaller-format outlets, and ends the session outside those hours — bypassed only on development hosts and for the scheduled-job entry points.
Honest limits. Error visibility is a global handler only — there are no captured exceptions, no test suite and no lint or test gate in the pipeline, which means the branch that deploys is effectively the release trigger. The read path trades consistency for throughput, and there are sequence-generation and secret-management issues I raised and would fix first. I know where the thin ice is; a system this old with this much money flowing through it deserves that answer rather than a marketing one.