NexStock
Inventory, sales and point of sale for businesses that outgrew the spreadsheet.
Web · iOS · Android

Dashboard 
Products 
Invoices

Home 
Till 
Stock history 
Products
Small businesses rarely fail on effort. They lose margin in the gaps — stock counted in one place and sold in another, a quotation that never became an invoice, a transfer nobody recorded. NexStock closes them by putting every sale, delivery, transfer and count through one stock ledger, branch by branch.
NexStock is StyloTech’s own product. We define, design, build, run and support it — the Laravel API, the React web app, the Flutter app for iOS and Android, the operator console, the public site and the deployment.
Because we operate it ourselves, the architecture we recommend is architecture we live with: the business is checked on every request, roles are enforced by the server rather than the screens, module boundaries are enforced in CI, and every migration is proven against an empty database before a release.
- App Store
- Google Play
- Web app
Both store listings are still to be published. Until then, the web app is live.
- Web
- iOS
- Android
- Backend
- Laravel 12
- PHP 8.3
- JWT
- TOTP two-factor
- DomPDF
- Web client
- React 18
- TypeScript
- Vite
- Tailwind CSS
- TanStack Query
- Zod
- Mobile client
- Flutter
- BLoC
- go_router
- Dio
- mobile_scanner
- Data
- PostgreSQL 16
- Redis
- S3-compatible storage
- Quality & delivery
- PHPUnit
- Deptrac
- Larastan
- Pint
- GitHub Actions
- Docker
- Product strategy
- UI/UX design
- Design system
- Technical architecture
- Frontend development
- Backend development
- Mobile development
- Database design
- Integrations
- Testing & QA
- Deployment
- Maintenance
What it does
Each part was built for a real job on the day, not for a feature list. The product’s own site walks through them in full.
Stock by branch, with a ledger
Every delivery, sale, adjustment and transfer writes a movement with the balance it left behind, and the branch’s count is updated under a row lock — so each number on screen has its history.
A till that keeps selling offline
The phone’s till sends each sale with its own reference. Without a connection it queues the sale under a provisional receipt number and syncs later; a replay returns the original invoice instead of selling twice.
A counter in the browser too
The web till works from the same catalogue and the same stock, with held orders and a cash register that is opened with a float and closed at the end of the day.
Transfers between branches
A transfer moves from draft to pending, in transit and completed. Pending reserves the stock at the source, dispatch takes it out and receipt puts it in — and a step out of order is refused.
Quotations that become invoices
A quotation converts into an invoice without re-entry, unless it has expired, been rejected or already been converted. A nightly job marks unpaid invoices overdue.
Credit notes and refunds
A refund at the till is recorded as a credit note, and raising one puts the goods back into stock. Credit notes carry their own payments.
Purchasing, received properly
Purchase orders run through approval to receipt. Each delivery gets its own goods received note, a receipt larger than the order is refused, and supplier bills are matched against both.
A supplier catalogue
Each supplier’s own SKU, cost price and lead time per product, with a preferred supplier marked for reordering.
Thirty-two reports
Stock, sales, purchasing and receivables reports generated on the server, saved, and e-mailed on a schedule — with CSV import and export of the catalogue and a full data export.
Documents in the business’s own style
A designer on the web sets the template, colour, font, header, footer and watermark for invoices, quotations and purchase orders; the phone prints receipts at 58 mm, 80 mm or A4.
Roles and two-factor sign-in
Owner, admin, manager and staff, enforced by the API on every route group. Two-factor sign-in by authenticator app with recovery codes, and an account locked after five failed attempts.
API keys and signed webhooks
Keys scoped to one business, and webhooks for invoices, quotations, payments and credit notes, signed with HMAC-SHA256, retried, and switched off after repeated failures.
One sale at the till, traced
A cashier rings up a cordless drill and two hammer drills on the phone and takes the payment. This is everything between tapping “Charge” and the stock ledger moving.
Read from pos_repository.dart, bootstrap/app.php, ResolveTenantContext, PosSaleController, RecordPosSaleAction and StockWriter. Every name below appears in the source exactly as written.
- 01
The phone sends it once, or keeps it
PosBloc submits the sale with its own client_reference and the sale time in UTC. Offline, or on a network error, the sale goes into the Outbox under a provisional OFF- receipt number and is sent when the connection returns.
- PosSaleSubmitted
- client_reference
- Outbox
- OutboxKind.sale
What it says when it refuses
A sale the server rejects on its merits is not queued: retrying a doomed request would hide the problem from the cashier, so it is shown to them instead.
- 02
The checks at the door
An API key, if one is presented, is resolved before anything else; the caller is rate-limited, and a platform under maintenance answers before any business logic runs.
- AuthenticateApiKey
- throttle:api
- MaintenanceMode
What it says when it refuses
More than 300 requests a minute from one user gets RATE_LIMITED; during maintenance every request gets platform.maintenance with a 503.
- 03
A signed-in user
The till’s routes sit behind JWT authentication.
- auth:api
- AUTH_UNAUTHENTICATED
What it says when it refuses
No token, or an expired one, is answered 401 AUTH_UNAUTHENTICATED.
- 04
…who belongs to this business
The tenant middleware reads the business from X-Business-Id, loads the caller’s membership and binds the business for everything that follows — including the company scope on every query.
- X-Business-Id
- ResolveTenantContext
- tenant_id
- tenant_role
What it says when it refuses
A missing header, a business the caller does not belong to, a suspended membership or a suspended business each has its own key: TENANT_HEADER_MISSING, TENANT_NOT_FOUND, MEMBERSHIP_INACTIVE, TENANT_SUSPENDED.
- 05
Any role may sell
The till carries no role gate, on purpose: owners, admins, managers and staff all ring up sales. Elsewhere the same four roles are enforced on route groups, and a role that is not admitted is refused with a 403.
- MembershipRole
- owner
- admin
- manager
- staff
- 06
Validated, and placed in a branch
Every line needs a whole quantity of at least one, and the total cannot be negative. The branch comes from X-Branch-Id, then the branch name, then the first branch allowed to sell; walk-in sales share one Walk-in customer, and a sale time in the future is pulled back to now.
- RecordPosSaleRequest
- X-Branch-Id
- pos.no_branch
What it says when it refuses
A malformed sale is answered 422 validation_failed with the errors per field; a business with no branch that may sell gets pos.no_branch.
- 07
An invoice, written once
RecordPosSaleAction looks for the client_reference first. A replay from the Outbox returns the invoice it already created; anything new is written with its lines as a paid POS invoice, inside one database transaction.
- RecordPosSaleAction
- replayOf
- invoices
- invoice_items
- 08
Stock moves through the Inventory contract
Sales never touches the stock tables. It calls the Inventory module’s StockWriterContract, which locks the branch’s stock row, takes the quantity off and records a movement with the balance it left — bundles expanded into their components first.
- StockWriterContract
- withdraw
- branch_stock
- lockForUpdate
- stock_movements
- 09
Paid, announced, printed
One payment row per tender, each capped at the total so change is never booked as revenue. The low-stock alert and the invoice event go out after the write, and the phone renders the receipt itself — 58 mm, 80 mm or A4.
- payments
- StockDepleted
- InvoiceRaised
- buildReceiptPdf
Engineering detail
Taken from the project source, not from marketing material.
- API
- Laravel 12 on PHP 8.3 as a modular monolith: 17 modules, each split into domain, application and infrastructure layers; 332 routes; 116 migrations building 94 tables in PostgreSQL 16; Deptrac reports 0 boundary violations and blocks CI
- Tenancy
- The business comes from X-Business-Id and the caller’s membership is re-checked on every request across 245 routes; role gates sit on route groups across 123 more; route-model binding runs after the tenant check, so a record from another business cannot be bound
- Web client
- React 18 with strict TypeScript and Vite, TanStack Router and Query, Zod at every API boundary, Tailwind CSS and Radix; 16 feature modules and 59 routes in English and French, plus an operator console on the same stack
- Mobile client
- Flutter with BLoC, go_router, Dio and get_it — 193 hand-written Dart files across 19 features and 85 routes, five bottom tabs, camera scanning, an offline outbox for five kinds of write, and receipts rendered on the device
- Data & operations
- PostgreSQL 16, Redis for cache and queues, S3-compatible object storage; separate worker and scheduler containers that mark overdue invoices, send expiry digests and back the database up every night
- Quality gates
- 601 PHPUnit tests with 4,662 assertions, Pint and Deptrac blocking in CI and Larastan at level 6 reported; every migration applied to an empty PostgreSQL 16 and the production image’s caches built before it counts as deployable; 109 Flutter tests besides
Inventory, sales and point of sale for businesses that outgrew the spreadsheet.
