Skip to content
Patdev101Public

About

Laravel 12 point-of-sale app for cashiers: checkout, cash sessions and sales history. Pulls live products, prices and stock from the Inventory System via API.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

POS System

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.

Getting started (fresh clone)

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=8002

Open 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.

Key .env values

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.

Tax rate health check

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.

Pricing: VAT-inclusive, per base unit

selling_price from the Inventory API is VAT-inclusive and priced per base unit (e.g. per Piece). Two things that follow from that:

  1. Selling a non-base unit scales by its conversion factor. Selling "1 Box" of a 12-piece product charges selling_price × 12, not selling_price × 1 — computed server-side in PosCheckoutController using the product unit's conversion_factor, never trusted from the client.

  2. 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 Sales
    

    The 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

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, void, and refund

  • Checkout deducts stock from Inventory (POST /api/inventory/out) and is idempotent — a repeated request with the same idempotency_key returns 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 stays completed — it never marks a sale voided while silently failing to restock.

Auto-refresh

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.

Testing

php artisan test

Runs 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.

Known limitations

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_ID is 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_RATE here and Inventory's VAT_RATE are still two separate .env values in two separate apps, but InventoryService::assertTaxRateMatchesInventory() now calls Inventory's GET /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 .env values 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 in PosCheckoutController, not a database row.
  • ✅ Void/refund had no race protection — fixed. SaleController::void()/refund() used to check $sale->status and 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's idempotency_key guard 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_CONNECTION is 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.

About

Laravel 12 point-of-sale app for cashiers: checkout, cash sessions and sales history. Pulls live products, prices and stock from the Inventory System via API.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages