Skip to content
Contact

Case studies / Tigg POS · Jul – Sep 2026

A tracked item cannot enter the cart incomplete

Businesses that track batches, expiry or serial numbers need that identity to survive every step. I designed batch and serial tracking across eight surfaces and built the POS frontend, prototyping the flows directly in code.

SystemsFrontendInventoryTagged release · Sep 2026In use (first-party report)

Role

UI/UX design across 8 surfaces, POS frontend

Timeline

Jul – Sep 2026

Team

CEO, senior frontend engineer, backend engineer, QA

Platform

POS web, Accounting web

Not mine

Backend, accounting-side build

Measured outcome

None measured

the key decision

Selection lives inside the line editor, so an item can't be added without its batch or serial.

how deep do you want to go?

8 surfaces
Diagram from the case study. Product screenshots are being added.
That's the brief. Switch to Story for the full argument, or Deep for states, iterations and implementation.
Chapter 01

Whose unit is this?

A customer buys three strips of paracetamol. Two come from a batch that expires in October, one from a batch that expires next March. Tigg POS used to record that sale as "Paracetamol 500 mg × 3". The invoice could not say which batch left the shop, or whether one of them had already expired.

The same gap showed up with phones. A shop sells two handsets, scans both serial numbers, and the cashier then taps "+" on the cart row to make it three. The cart now says three phones and knows two identities. A month later, when one comes back under warranty, nobody can say which unit it is.

Before this work, the POS treated every product as a quantity. A cart line was product × unit × quantity, and two lines with the same product and unit merged into one. That works for tea and biscuits. It fails for anything where the units are not interchangeable.

A · Lines merge by product Scan 1 · Paracetamol 500 mgBatch B-2506-A · qty 2 Scan 2 · Paracetamol 500 mgBatch B-2507-D · qty 1 merge key:product + unit Cart shows Paracetamol 500 mg × 3 Which batch did the customer get? Which one expired? The line cannot say, so the invoice cannot either. B · Quantity drifts from identity Phone X2 128 GBqty 2 · SN 88120, 88121 serials = quantity +1 in cart Phone X2 128 GBqty 3 · SN 88120, 88121 2 serials for qty 3 The quantity stepper on the cart row sits outside the line editor. Nothing stops the cashier from changing it after the serials were scanned. Without a guard the invoice would record three phones and two identities.
Reconstruction, fictional dataTwo ways the quantity model lost identity. Left: lines merged by product and unit, so two batches collapsed into one line. Right: the quantity could change on the cart row after the serials were scanned.

The brief asked for batch and serial number tracking across Tigg. The problem underneath it was narrower and harder: identity has to survive every step, from product setup to the sale, the invoice, the return and the report. A settings toggle does not do that on its own.

Chapter 02

Brief, role and constraints

The CEO wrote the brief as eight design tasks, one per surface, in July 2026. I was the only UI/UX designer on the team.

MeOthers (by role)
Design of all eight surfaces, POS and accountingYesCEO wrote the brief and set priorities
POS web frontendYesSenior frontend engineer reviewed and merged
Accounting web frontendNoSenior frontend engineer
Backend: stock, validation, reportsNoBackend engineer
TestingNoQA
Launch promo artworkYes

So the claim is specific: I designed all eight surfaces and built the POS frontend. I did not build batch and serial tracking end to end. The accounting screens and the backend belong to other people.

Constraints that became requirements

  • Two independent switches. The brief said a business can turn on batch tracking, serial tracking, or both. A product can be flagged for either, but a product flag only counts when the organisation has that switch on.
  • VAT-inclusive prices. Many Tigg businesses price with 13% VAT included. A batch carries its own selling price, and that price has to reach the line as the final price, without VAT being added a second time.
  • Barcode scanners. A scanner types characters and presses Enter, fast. The field has to accept a stream of scans, reject a duplicate, and keep focus where the next scan goes.
  • Warehouses per location. Whether a serial is in stock depends on the location's default warehouse. A location without one cannot check serials at all.
  • The server knows the truth. Only the backend knows whether a serial is in stock, already sold, or eligible for return. The POS can check for blanks and duplicates locally; everything else is a question it has to ask.
  • Devices. The POS runs in desktop and tablet browsers at the counter.
Chapter 03

What I knew, and how

There was no merchant interview or usability test for this feature. I want to say that plainly, because the rest of the case depends on reasoning rather than observed behaviour.

What I did have:

  • The brief and the CEO. The CEO explained each task when it was assigned. Across my work, what I know about user problems comes mostly from QA, support and the CEO.
  • Zoho, which I used as a reference product at the time.
  • Domain modelling. Working out what a batch, a serial number and an order line had to carry, and what the server could answer, was most of the early work. It is technical discovery, and I count it as research.
  • Designing in the browser. I designed the POS flows directly in code, prototyping them in the running app against sample data before the real stock endpoints were ready. That let me feel how a scanner stream, a focus change or a held dialog behaved, which a static frame does not show.

A competitor review, done later

While writing this case in 2026, I compared the shipped design with public documentation for other products. This review was not part of the original work.

ProductSerial or lot at the saleRequired before the sale completes?Expired stock
Odoo POSA dialog asks the cashier to type or scan itNo: its documentation says the sale can complete without itHandled in inventory, not at the counter
ERPNextA serial and batch bundle per transactionYesBatches fetched by a rule such as expiry
Zoho InventoryBatches chosen on the invoiceNot documentedFirst-expiring batch listed first
BUSYBatches listed with stockNot documentedCan hide expired batches
Square, Loyverse, Shopify POSNo native batch or expiry; Square has no native serialsn/an/a
Tigg POS (shipped)The line editor asks for exactly what the product needsYesWarn and confirm

Tigg ended up between Odoo's permissive "record it if you can" and ERPNext's heavier bundle model. Identity is required at the sale. Expiry is a warning.

Who I designed for

These are assumption-based proto-personas, drawn from the brief and the product's scope, not from interviews. The shop types are illustrative.

  • The counter cashier at a pharmacy-type shop, with a queue. Needs the next required field focused, nothing half-finished in the cart, and errors that say how to fix them.
  • The electronics shop owner, handling a warranty return of one unit out of three. Needs to return a specific serial and see each unit's status.
  • The admin setting up the business. Needs two plain switches that say what they hide.
  • The inventory person, looking for stock that expires in the next 30 days, per warehouse.
Chapter 04

One capability, eight surfaces

I arranged the eight surfaces along the life of a tracked item: switch the feature on, flag a product, bring stock in, move it through documents, sell it, inspect it, and drill into it. Seven of them are covered here.

Enable Set up Stock in Transact Sell Inspect Drill down Orgsettings Productsettings Openingbalance Inventorydocuments POS entry& returns Detail &overview Item detailviews Designed by me POS frontend by me Accounting frontend by others designed built by me in POS built by others in accounting prototype only, not shipped
DiagramSeven of the eight surfaces I designed, and who built each. I built the POS side; the senior frontend engineer built the accounting side. Item-detail views stayed a prototype and did not ship.

Two switches, four modes

The organisation settings got two cards in the style the existing inventory settings already used, each with On and Off. The copy says what Off does, because that is the surprising part:

Batch Number Tracking. Track stock in numbered batches with their own manufacture and expiry dates. Turning this off hides the Batch field on order lines, even for products still flagged as batch-tracked.

The product form then gets two Yes/No questions, "Batch Tracking" and "Serial Number Tracking". They are hidden for service products, and hidden when the organisation switch is off, so an admin never sets a flag that does nothing.

The two levels resolve to one of four modes per product: untracked, batch, serial, or both. The line editor shows only the inputs the mode needs.

Org: batch off · serial off serialno serial batchno batch nonenonenonenone Org: batch on · serial off serialno serial batchno batch batchselect batchselect nonenone Org: batch off · serial on serialno serial batchno batch serialSN input none serialSN input none Org: batch on · serial on serialno serial batchno batch bothSN → batch batchselect serialSN input none Rows and columns are the product flags. "none" = untracked: the line behaves exactly as before and merges by product + unit. In the "both" mode the serial input comes first; when the last serial is accepted, focus moves to the batch select.
DiagramTracking mode by organisation state (panels) and product flags (cells). Half of the combinations are untracked, and only one needs both inputs.

Keeping the mode to a single resolved value mattered more than it looks. Every later surface, from the cart line to the refund dialog and the reports, asks the same question, "what does this product need?", and gets the same answer.

Chapter 05

The page I deleted after two days

My first version was a standalone selection page. Tapping a tracked product opened a full screen titled "Select Batch & Serial Numbers", with its own quantity stepper, a scan field that turned serials into chips, a list of batch cards sorted by expiry, a "+" to add a batch, and an "Add to Cart" button. I built it against sample data at the end of July 2026 and removed it two days later.

Selection moved into the line editor, the screen that already opens when a cashier edits a line's unit, rate or discount.

PROTOTYPE PAGE · 29–31 JUL OUTCOME SHIPPED LINE EDITOR Own quantity stepper Serial scan field + chips Batch cards, expiry-sorted, 120-day amber "+" opens the add-batch modal "Add to Cart" button A route of its own Mock batch data merged kept, same copy reshaped moved replaced removed× replaced, 12 Aug The line's own quantity (one source) Serial Numbers (k/N) field + chips Batch select with two-line options "Add New Batch" pinned in the menu Save on the line + deferred add Real batch APIs and server validation
DiagramWhat survived the prototype, what was replaced by something the line editor already had, and what was removed.

I didn't write down my reasons at the time. Reading the prototype against what shipped, these are the ones I can reconstruct:

  • Two quantities. The page had its own stepper. The line had a quantity too. Two sources for one number is how a line ends up with three phones and two serials.
  • The line editor already owned price. Rate, unit, discount and the VAT-inclusive handling already lived there. A batch's selling price had to land in that same logic, not in a copy of it.
  • One place to come back to. If a line was ever incomplete later, I would need to send the cashier somewhere to fix it. That place should be the editor they already know, not a second page.
  • Fewer ways to add. "Add to Cart" on a separate page was a second way to put an item in the cart, with its own rules.

What survived were the parts that were about the counter, not the page: scan-first serial chips, the duplicate and limit messages (same wording), batches sorted by expiry, the amber warning under 120 days, and the fields of the new-batch dialog.

Chapter 06

Key decision: a tracked item waits outside the cart

The rule I built the rest around is in the title: a tracked item cannot enter the cart incomplete.

When a cashier taps or scans a tracked product, it does not go into the cart. The line editor opens with a pending line. The cashier scans serials or picks a batch there. Save puts it in the cart as its own line. Cancel leaves nothing behind.

grid tap · scanner · barcode modal Tap or scan a product Resolve mode (org + product) tracked? no Add to cart nowmerge by line identity yes Line editor · pendingnot in the cart yet Cancel Nothing addedcancelling leaves no trace Save complete? no Toast + field highlightsstay in the editor yes Saved as its own cart linebatch · serials · batch price
DiagramDeferred add. Every entry point, including the barcode scanner, goes through the same pending line editor for tracked products. Untracked products still go straight into the cart.

The alternative was to add first and nag later: put the item in the cart and mark it as missing something. I chose not to. A half-filled line in a shared cart can be paid for, printed, or forgotten. Holding it outside the cart means the cart only ever contains lines that can be sold.

When are two lines the same line?

The old cart merged any two lines with the same product and unit. I changed what "the same" means. Two lines merge only if they share product, unit and batch, and neither carries serial numbers. A serialised line is always its own line, because each unit is a different thing.

INCOMINGRULECART RESULT Paracetamol · B-2507-D · 2 Paracetamol · B-2507-D · 1 same product, unit, batchno serials on either line → merge Paracetamol · B-2507-D · 3 Paracetamol · B-2507-D · 2 Paracetamol · B-2506-A · 1 different batch→ two lines Paracetamol · B-2507-D · 2 Paracetamol · B-2506-A · 1 Phone X2 · SN 88120 Phone X2 · SN 88125 either line has serials→ never merge Phone X2 · SN 88120 Phone X2 · SN 88125
DiagramThe line-identity rule with fictional items. Same batch merges; a different batch splits; lines with serials never merge.

This was the most consequential choice in the feature, and no mock-up would have shown it. It decides what the invoice says, what a return can reverse, and what the report counts.

Product screenshot, test company dataThe shipped line editor for a product tracked by batch and serial.
Chapter 07

At the counter: scan first

Most of the interaction design is in two fields: Serial Numbers and Batch.

The serial field

The label carries a counter, "Serial Numbers (0/3)", in red until it reaches the quantity, then green. The field is focused when the editor opens, and the placeholder says how it works: "Scan or type, then press Enter". Enter submits, because a scanner ends every scan with Enter. After each accepted serial, the input clears and focus comes straight back, so the next scan lands in the right place.

Each serial becomes a removable chip set in a monospace font. Looking back, that was a legibility choice: serials mix letters and digits, and a fixed-width face makes 0 and O, or 1 and l, easier to tell apart when a cashier is checking a chip against a box.

The messages say what happened and what to do:

SituationWhat the cashier sees
Same serial scanned twice"That serial is already added to this line." (checked locally, no request)
Server says noThe server's reason, or "That serial is not valid."
Network fails"Could not validate the serial number — please try again."
Count reached"Quantity limit reached — increase quantity to add more." The input is disabled.
Quantity lowered after scanningHow many extra serials there are, and to remove them or raise the quantity
Location has no default warehouseThe field is disabled: "This location has no default warehouse set. Contact an admin to configure one."

"Not valid" and "could not check" are deliberately different. A dropped connection is not evidence that a serial is wrong, and it should not read like one.

Product screenshot, test company dataScanning serials into a line.

When a product needs both

For a product tracked by both, the serial field comes first. When the last serial is accepted, focus moves to the Batch select. My reasoning, in retrospect: scanning units is the repeated action and the batch is chosen once, so the repeated action should need no extra tap. The batch could have come first; this order keeps the cashier's hands on the scanner longest.

Chapter 08

Warn, don't block: expiry

The Batch select opens on focus and lists batches earliest expiry first. Each option shows the batch number, "Expires" with the date, and the balance on the right. Under 120 days the date turns amber. Past its date the option reads "Expired" in red. The colour is never the only signal; the word is always there.

A new batch can be added from the top of the menu, with Batch No., Expiry Date and Selling Price required, and Manufacture Date and Purchase Price optional. Once saved, it is selected and its price applied.

Choosing an expired batch

Picking an expired batch does not apply it. The POS holds the choice and asks:

Batch Already Expired. This batch has already expired. Do you still want to continue?

Continue applies it. Cancel drops the held choice and the field goes back to what it showed before, so the screen never displays a batch the line doesn't have.

Idleclosed Loadingspinner Optionssorted by expiry, earliest first focus expired? pick Appliedbatch no., dates, price* no Confirm dialog"Batch Already Expired"Continue (red) · Cancel yes, hold the apply Continue Cancelledpending apply dropped;previous value shown Cancel / Esc New Batch modalfrom "Add New Batch",pinned at menu top on save: lists refresh, the newbatch is selected, price applied Off-page fallbackselected batch rebuilt from stored data Invalid after failed save"Select a batch to continue." * rate is applied only for users with thepermission to change rates
DiagramBatch select states. An expired choice is held until the cashier confirms; cancelling restores the previous value.

Expired batches are warned about, not blocked. That is how it shipped, and it is a policy rather than a law of nature. The reasons I would give for it now:

  • a shop may have a legitimate reason to sell or clear stock at or near its date;
  • the date on record may be wrong, and the person at the counter is the one holding the box;
  • blocking the counter while a queue waits is expensive, and the cashier has no way to fix the data there.

Other products choose differently: BUSY can hide expired batches, and Odoo and ERPNext pick by expiry in inventory. A setting per organisation (warn, hide, or require a manager) with a note when someone overrides the warning is the obvious next step.

Product screenshot, test company dataThe warning a cashier sees when choosing an expired batch.
Chapter 09

Repair, then return by serial

When the quantity changes later

The cart row still has a quantity stepper outside the editor. A cashier can raise a phone line from two to three after scanning two serials. The first version of the guard stopped the save with a toast. That told the cashier something was wrong but not where.

The shipped version routes. When Save or Pay is pressed, the POS finds the first line that breaks the rule, shows "Fill in the required batch/serial number details before saving the order.", and opens that line in the editor with focus in the serial field. The same guard runs on the buttons of every order type.

1 · Qty raised in cart 2 · Pay pressed 3 · Guard finds line 1 4 · Editor opens 5 · Complete, pay Cart Phone X2 128 GBSN 88120, 88121 − 3 + Charger 25 W× 1 Pay Cart Phone X2 128 GBSN 88120, 88121× 3 Charger 25 W× 1 Total Rs 1,13,700 Pay Fill in the requiredbatch/serial numberdetails before savingthe order. Phone X2 · × 3first mismatch Charger · × 1 Phone X2 128 GB Serial Numbers (2/3) Scan or type, then… 88120 88121 focus is alreadyin the serial field Save Phone X2 128 GB Serial Numbers (3/3) …120 …121 …122 saved → cart → Paypasses the guard Pay The same guard runs on the save and pay buttons of every order type.A follow-up fix on 15 Sep also redirects when the quantity changes from outside the editor.
Reconstruction, fictional dataThe repair path. The guard does not only refuse; it opens the one line that can fix the problem.

At payment, every serial is checked with the server once more before the invoice is created, in case stock changed while the order was open.

Returns reverse units, not numbers

A return has to say which units came back. The refund dialog lists each invoice line with what can still be returned: the quantity sold minus what was returned before. Lines with nothing left are hidden. For a serialised line, the cashier ticks the specific serials; each one is checked with the server as it is ticked. The counter stays red until the ticked serials equal the return quantity, a remark is required, and only the ticked serials are sent back. A returned item is not for sale again unless it is restocked.

Sell three phones, then return one by serial

Step 1 / 7

Pending line

The phone opens the line editor, not the cart

Phone X2 128 GB is serial-tracked, so tapping it opens a pending line. The cashier sets the quantity to 3. Nothing is in the cart yet.

Tracking
Serial
Quantity
3
Serial Numbers
0/3
In cart
No
Interactive reconstruction, fictional dataFictional items, serials and prices. Prices are VAT-inclusive.
All steps as a table
#StateWhat happens
1Pending lineThe phone opens the line editor, not the cart. Phone X2 128 GB is serial-tracked, so tapping it opens a pending line. The cashier sets the quantity to 3. Nothing is in the cart yet.
2IncompleteTwo serials scanned, save refused. Two scans become chips. The cashier presses Save too early; the line stays in the editor and says what is missing.
3SavedThe third scan completes the line. The counter turns green and Save puts the phones in the cart as their own line, with the serials shown under the product name.
4InvoicedSerials re-checked, invoice issued. At payment the POS asks the server about all three serials again, then creates the invoice. The invoice lists the serials under the product.
5Refund openThe customer brings one phone back. The refund dialog shows what can still be returned. The cashier ticks the line, sets quantity 1, and ticks the serial on the box.
6Returned 1Only the ticked serial goes back. With a remark filled in, Proceed to Payment completes the refund. Serial 88121 is returned; it is not for sale again unless it is restocked.
7Refund openA week later, only two can come back. The dialog caps the quantity at 2. Ticking 88121 again is rejected by the server check, shown beside that serial. 88122 is accepted.
Invoice · 3 × Phone X2 SerialStatus 88120sold 88121sold 88122sold quantity 3 · returned 0 refundable = 3 − 0 = 3 Return 1 · one unit back Serial Numbers (1/1 selected) 88120 88121valid 88122 only 88121 is sent back now returned 1 · refundable 2 Return 2 · a week later Quantity (max 2) · 1 88120 88121rejected 88122valid 88121 shows "(checking...)", then the server's reason inline Proceed enabled at 1/1 Rules: refundable = sold − already returned · lowering the quantity trims the ticked serials · the counter is red until ticked = quantity · "Proceed to Payment" stays disabled until at least one line is ticked and none mismatches · "Remarks" is required. Next step: the picker still lists serials returned earlier (88121); the server rejects them today. Hiding them in the picker would be clearer.
Reconstruction, fictional dataTwo partial returns against one invoice, the same example as the stepper.
Product screenshot, test company dataReturning one unit by serial.
Chapter 10

What shipped, and what I don't know

surfaces designed
8surfaces designed
tracking modes from two switches
4tracking modes from two switches
states designed for the serial field
13states designed for the serial field
measured outcome
Nonemeasured outcome

The POS side was released in a tagged release in September 2026. Along with the sale and return flow, it included:

  • one Batch / Serial Number page in Inventory, with a tab for each (two separate pages were merged into one in late August), and a detail drawer per batch or serial with its dates, prices, status and recent transactions;
  • serial statuses Available, Not Available and Oversold;
  • batch and serial numbers under the product name on invoices, credit notes and sales orders;
  • a Product Batch Report that answers "what expires in the next N days?", grouped by product or warehouse, and a Product Serial No Report filtered by status;
  • report permissions that disappear when the organisation switch is off.
Product screenshot, test company dataThe batch report, filtered to stock that expires within 30 days.

Because I designed these flows in the browser and built the POS frontend myself, the POS behaviour on this page is the behaviour in the release. The accounting screens were built by the senior frontend engineer from my designs; I haven't reviewed them closely enough to say how closely they match.

Feedback

Good feedback.Reported to me by the CEO

That is the whole of the feedback I can cite. It is informal, and I don't know which businesses it came from.

What is not measured

Measured outcome: none. There are no adoption numbers, no count of businesses with tracking switched on, no checkout-time data, no error rates and no usability test. If I could instrument it, I would look at:

  1. how often a tracked line is sent back by the repair route, per order type;
  2. time from tapping a tracked product to saving its line;
  3. serial check failures by reason: duplicate, refused, network;
  4. how often cashiers continue past the expired-batch warning versus cancel.

I also designed the launch promo artwork, on the Figma page "Batch and Serial No".

Figma designLaunch promo I designed for the feature. Marketing artwork, not flow design.
Chapter 11

What I learned

Line identity is a domain decision, not a code detail. Deciding when two cart lines are "the same" shaped the invoice, the return and the report. It was invisible in every mock-up and it was the choice that mattered most.

Don't let incomplete things into shared state. Holding a tracked item outside the cart until it was complete removed a whole class of half-filled lines, instead of adding warnings to catch them.

Put enforcement where the fix is. A toast that says "something is missing" hands the problem back to the cashier. Opening the exact line, with focus in the right field, solves it. The same thinking applies to the expiry warning: warn at the moment of choice, where the person holding the box can decide.

Deep dive 12

Every state of the serial field

The serial field is small, but it is where the cashier spends most of the time on a tracked sale. I designed thirteen states for it: a happy path of four, four rejections, and five imposed from outside the field.

Empty(0/N) red · autofocus Checkinginput + button disabled Added (k/N)chip · focus returns Complete (N/N)green · focus → Batch Enter valid k = N next scan while k < N Transport failure"please try again" Rejectedserver message shown Duplicatelocal check, no call Limit reachedinput disabled at N network error refused Enter, already on line k ≥ N Bad requestnot shown to cashier request fault Rejected entries are not added to the line. "Invalid" and "could not check" are different states. IMPOSED FROM OUTSIDE THE FIELD Warehouse loading"try again in a moment" Blocked (denied)no default warehouse Incomplete after save"Add m more…" Overfilledquantity lowered later
DiagramSerial field states. The top row is the scan loop; rejected entries are never added to the line. The dashed row holds states set by something outside the field: the location, a failed save, or a quantity change.
StateTriggerWhat the field does
EmptyQuantity N, nothing scannedRed counter (0/N), focused, placeholder explains Enter
CheckingEnter or AddInput and button disabled; button reads "Checking..."
AddedServer acceptsChip added, input cleared, focus returned
CompleteCount equals quantityGreen counter; for batch-and-serial products, focus moves to Batch
DuplicateSerial already on this lineRefused locally, no request
RejectedServer refusesThe server's reason shown under the field
Transport failureNetwork error"please try again"; nothing added
Bad requestA fault in the request itselfNot shown to the cashier; treated as a bug
Limit reachedCount at quantityInput disabled, with a hint to raise the quantity
Warehouse loadingLocation's warehouse still loading"try again in a moment"
BlockedLocation has no default warehouseField disabled, message names who can fix it
Incomplete after saveSave pressed earlyField marked, "Add m more serial number(s) to continue."
OverfilledQuantity lowered after scanningSays how many extra, and the two ways out

Two small behaviours carried a lot of weight. Focus is handed back after the field re-renders, not during, because otherwise the next scan landed nowhere. And a quantity with a decimal (2.5 of a serialised item) asks for the whole number of serials below it, so the rule never asks for half a serial.

Deep dive 13

Where each rule is enforced

The serial count is checked four times, on purpose, because the quantity can change in more than one place.

LayerWhat is checkedWhat the cashier sees
EntryBlank, duplicate, limit, warehouse, server check per serialMessage under the field; nothing added
Line saveSerial count equals quantity; batch chosen if neededFields marked; stays in the editor
Order save or payFirst line in the cart that breaks the ruleToast, then that line opens in the editor
PaymentCount again, and every serial re-checked with the serverServer's message; stays on payment
RefundRefundable quantity; ticked serials equal quantity; each serial checked as a return; remark presentPer-serial errors; Proceed disabled until complete

Edge cases I designed for

  • The same product from two batches becomes two lines; the same batch twice merges.
  • A serialised product added twice never merges.
  • Quantity raised from the cart row: routed back to the line on save or pay.
  • Quantity lowered after scanning: the field says how many extras to remove.
  • A duplicate scan is refused without a request.
  • The network drops mid-scan: the serial is not accepted, and the message says to try again, not that the serial is wrong.
  • The organisation switches tracking off: the fields disappear, even for products still flagged.
  • A location without a default warehouse: serial entry blocked, with a message for the admin.
  • A selected batch that isn't on the current page of search results still shows correctly.
  • Expired batch chosen, then cancelled: the previous choice stays.
  • A batch price under VAT-inclusive pricing is the final price, not taxed again.
  • A user without permission to change rates keeps the line's rate; the batch price is not applied.
  • A second partial return only offers what is left.
  • A serial that was already returned, or wasn't in this sale, is refused by the server check.
  • Inactive products still appear in historical reports.
Deep dive 14

Building the POS frontend

I built the POS side over about seven weeks, from late July to the release in September 2026. The order below is the order the behaviour settled in.

  1. Jul 2026Brief in eight tasksThe CEO wrote the design tasks, one per surface.
  2. Late JulStandalone page, then the line editorThe selection page was built on sample data and removed two days later; selection moved into the line editor.
  3. Mid AugReal stock data and server checksSample data replaced by real batches; each serial checked with the server as it is entered.
  4. AugScanner path and focusScanning a tracked product now opens the pending editor too; focus handed back reliably between scans.
  5. Late AugOne management page, reports, split linesBatch and serial pages merged into one tabbed page; batch and serial reports; two batches of one product became two lines.
  6. Late AugReturns by serialRefundable quantity accounts for earlier returns; each ticked serial is checked.
  7. Sep 2026Route to the lineThe save guard stopped only toasting and started opening the line that needs fixing.
  8. Sep 2026Tagged releaseReleased in a tagged release in September 2026, with small fixes a week later: finding products by code when adding a batch, and lists refreshing after a new batch.

Tradeoffs

  • One server check per serial. Simple to explain and precise in its errors ("this serial" rather than "one of these"). The cost is one request per serial at payment, which run in parallel.
  • Required identity. I chose data quality over a slightly faster sale. A cashier can't finish without the serials. For a shop that will handle the warranty return later, that is the point.
  • A full editor, not a pop-up. The line editor has more context than a dialog, and it is the same place the repair route opens. It is one more screen change than a small pop-up would be.
  • Warn, not block, on expiry. Flexible, but it relies on judgement, and today there is no record of who continued past the warning.

Keeping the release clean

The feature was developed alongside other unreleased work. To ship only this feature, I rebuilt the release from the production line of work and carried the tracking changes across, so nothing unfinished from elsewhere reached customers. It took more than one attempt. I think of this as design work for my teammates: QA and the reviewer were testing exactly what would ship.

There are no automated tests specific to this feature. QA tested it in the test environment before release.

Deep dive 15

Validation plan and notes on sources

What I would test with people

A moderated test with three to five cashiers, thinking aloud, on five tasks:

  1. sell three serialised phones by scanning;
  2. sell from an expired batch;
  3. change a quantity from the cart, then pay;
  4. return one unit of three;
  5. find the batches that expire in the next 30 days at one warehouse.

I would watch for where the cashier looks after the repair route opens the editor, and whether "Expired" is read before or after the dialog appears.

What I would change next

  • A per-organisation expiry policy (warn, hide, or require a manager), with a note on override.
  • An optional first-expiring batch pre-selected, with manual override.
  • Hiding already-returned serials in the refund picker, instead of relying on the server to refuse them.
  • Bikram Sambat display for expiry dates, if customers ask for it; dates are shown in AD today.
  • Bringing POS and accounting copy and behaviour into one written spec.

Sources

  • Product behaviour, states and microcopy: the released POS, which I built.
  • Role and scope: the CEO's brief and my own account.
  • Reasons marked as retrospective (the deleted page, serial before batch, monospace chips, warn not block) are my reading after the fact; I didn't record them at the time.
  • Competitor comparison: public documentation for Odoo, ERPNext, Zoho, BUSY, Square, Loyverse and Shopify, read in September 2026, after the work.
  • Every figure uses fictional products, serials and prices. Diagrams and wireframes are reconstructions; screenshots will come from a test company.
Deep chapters (states, iterations, implementation) are hidden in Story mode. Switch to Deep at the top to read them.