Laravel 12 point-of-sale app used by cashiers to check out sales, manage
cash sessions, and view sales history. It has no product data of its
own — every product, price, and stock level is fetched live from the
separate Inventory System (../inventory) over a token-authenticated
API, and every sale deducts/restores stock there too.
Requires PHP 8.3+ (with sqlite3, pdo_sqlite, mbstring, openssl, curl,
xml, zip) and Composer 2. The database is a single SQLite file, so no
database server is needed.
composer install
cp .env.example .env # Windows: copy .env.example .env
php artisan key:generate
touch database/database.sqlite # Windows: type nul > database\database.sqlite
php artisan migrate
php artisan storage:link
php artisan pos:create-admin # prompts for name, email, password
php artisan serve --port=8002Open http://127.0.0.1:8002 and sign in with the admin you just created. Add managers and cashiers afterwards from Manage Users.
Set INVENTORY_API_URL and INVENTORY_API_TOKEN in .env to connect the
Inventory System (see below); without it the product list will be empty.
| Variable | Purpose |
|---|---|
INVENTORY_API_URL |
Base URL of the Inventory app (e.g. http://127.0.0.1:8001). |
INVENTORY_API_TOKEN |
Bearer token sent to the Inventory API. Must match Inventory's .env INVENTORY_API_TOKEN. |
POS_LOCATION_ID |
The single location this POS terminal sells from. Never client-supplied — read from config only, so a cashier (or a tampered request) can't sell from/deduct a different location. |
POS_TAX_RATE |
Store-wide VAT percentage (e.g. 12). Fixed server-side config — a client-supplied tax rate is always ignored. Must be kept in sync with Inventory's VAT_RATE — checked automatically at runtime (see below), but still two independently-set .env values. |
Before serving the product catalog or a barcode lookup,
PosProductController calls InventoryService::assertTaxRateMatchesInventory(),
which fetches Inventory's GET /api/config (result cached 5 minutes) and
compares its vat_rate against this app's own POS_TAX_RATE. If they're
confirmed to differ, the request fails with 503 instead of silently
charging the wrong tax. If /api/config itself can't be reached — as
opposed to reachable but mismatched — that's logged as a warning and
ignored, so a transient blip on that one endpoint doesn't take down
checkout. This doesn't eliminate the two-.env-values problem (see Known
Limitations), it just makes a mismatch loud instead of silent.
selling_price from the Inventory API is VAT-inclusive and priced
per base unit (e.g. per Piece). Two things that follow from that:
-
Selling a non-base unit scales by its conversion factor. Selling "1 Box" of a 12-piece product charges
selling_price × 12, notselling_price × 1— computed server-side inPosCheckoutControllerusing the product unit'sconversion_factor, never trusted from the client. -
VAT is disclosed, never added on top. The shelf price shown on the product card is exactly what's charged — same as a Jollibee menu price or a supermarket shelf tag. The checkout total is
subtotal − discount; VAT is only ever computed backward out of that number for the receipt/cart display:VATable Sales = Total ÷ (1 + tax_rate / 100) VAT = Total − VATable SalesThe cart panel and receipt show a small "Includes VAT (12%)" (or "VAT-exempt sale") line under the Total — never a separate line added to reach the total.
Discounts are not a free-typed amount or percentage — a cashier can
only pick one of the fixed statutory categories in config('pos.discount_types')
and record the qualifying ID number:
| Category | Percent | VAT-exempt |
|---|---|---|
| Senior Citizen (RA 9994) | 20% | Yes |
| PWD (RA 10754) | 20% | Yes |
| Solo Parent (RA 11861) | 10% | No |
The percentage and VAT-exemption flag always come from that server-side
config — never from the request — so a cashier can't grant an arbitrary
discount. The two computation paths differ (see PosCheckoutController):
- VAT-exempt (Senior/PWD): VAT is backed out of the gross price first, the discount applies to that VAT-exclusive amount, and the sale becomes fully VAT-exempt (₱0 tax charged) — matching the actual BIR rule.
- Non-exempt (Solo Parent): the discount comes off the gross VAT-inclusive price directly; VAT is still just the disclosed component of what's left.
- Checkout deducts stock from Inventory (
POST /api/inventory/out) and is idempotent — a repeated request with the sameidempotency_keyreturns the original sale instead of double-charging/double-deducting. - Void and Refund both restock every sale item back into Inventory
(
POST /api/inventory/in), using the exact product/unit/location recorded on the sale. A sale that's already voided/refunded is rejected outright, so stock can never be added back twice for the same sale. If restocking any item fails, the whole void/refund is aborted and the sale stayscompleted— it never marks a sale voided while silently failing to restock.
The product catalog on the POS screen re-fetches every 15 seconds (paused while the tab isn't visible, and instantly on tab focus) so a price or stock change made in Inventory shows up without a manual page reload. Items already in the cart keep their price at the time they were added — the same as any real POS.
php artisan testRuns against an in-memory SQLite database (see phpunit.xml, which also
pins POS_TAX_RATE=0 so tests are deterministic regardless of the local
.env value). External calls to the Inventory API are mocked with
Http::fake() in the checkout tests.
Read this before assuming a feature exists or extending one — these are current, deliberate gaps, not oversights to silently "fix":
- Single-location terminal, by design.
POS_LOCATION_IDis one value per deployed instance — this app has no concept of a cashier switching locations at runtime. Multiple physical stores means multiple deployed POS instances, each with its own.env. - No offline mode. Every checkout, void, and refund is a live call to
the Inventory API (
INVENTORY_API_URL). If Inventory is unreachable, the POS cannot sell — there is no local queue-and-sync-later behavior. - ✅
Tax rate duplication— now checked, not just documented.POS_TAX_RATEhere and Inventory'sVAT_RATEare still two separate.envvalues in two separate apps, butInventoryService::assertTaxRateMatchesInventory()now calls Inventory'sGET /api/config(cached 5 minutes) before serving the product catalog or a barcode lookup, and refuses (503) if the rates are confirmed to differ. A hiccup reaching that one endpoint is logged and swallowed rather than blocking checkout — only a confirmed mismatch blocks. The two.envvalues still have to be set correctly by hand; this only makes drift loud instead of silent. - The statutory discount table (
config('pos.discount_types')) is hardcoded to three Philippine categories (Senior/PWD/Solo Parent) — adding a new discount type means editing that config and the checkout math inPosCheckoutController, not a database row. - ✅
Void/refund had no race protection— fixed.SaleController::void()/refund()used to check$sale->statusand then restock and save — two concurrent requests (a double-click) could both read "not voided yet" and both restock the same sale before either one's status update landed. Both methods now atomically claim the sale first (Sale::where('id', ...)->where('status', $originalStatus)->update(...)) — only one request can win that conditional update, so the second request fails immediately with "already voided or refunded" before ever calling Inventory, instead of racing it. If the restock call itself fails after the claim succeeds, the claim is reverted so the sale isn't left stuck "voided" with stock never actually restored. Checkout'sidempotency_keyguard is unrelated to this and still the only idempotency mechanism for duplicate submissions of the same intended action (as opposed to concurrent void/refund attempts, which is what this fixes). - No queue workers assumed running.
QUEUE_CONNECTIONis effectively synchronous for anything this app dispatches — a slow Inventory API response blocks the checkout request until it completes or times out; there's no background retry. - Cart prices are snapshot-locked, deliberately. An item added to cart keeps its price even if Inventory changes that product's price seconds later (matching real POS behavior) — this means a stale browser tab can checkout at an old price if left open long enough between the 15-second refreshes; there's no server-side re-validation of price at checkout time against the latest Inventory value.
- No automated coverage for the Inventory-integration edge cases beyond
what
Http::fake()mocks in the checkout tests — real network failures, timeouts, and partial-failure scenarios (e.g. stock deducted in Inventory but the POS's own sale record fails to save afterward) aren't exercised by the test suite.