A full-stack cannabis dispensary point-of-sale and inventory management system built with PureScript (frontend) and Haskell (backend) on PostgreSQL — emphasizing type safety, functional programming, and reproducible builds via Nix.
- Frontend Documentation — PureScript/Deku SPA architecture, async loading pattern, page modules, services
- Backend Documentation — Haskell/Servant API, database layer, transaction processing, inventory reservations
- Nix Development Environment — Setup, TLS, sops secrets management, service scripts, and test suite
- Dependencies — Project dependency listing
- To Do list — Planned features and optimizations
- Security Recommendations — Planned security and authentication upgrades
- Comprehensive Product Tracking: Detailed cannabis product data including strain lineage, THC/CBD content, terpene profiles, species classification, and Leafly integration
- Real-Time Inventory Reservations: Items are reserved when added to a transaction cart, preventing overselling during concurrent sessions. Reservations are released on item removal or transaction cancellation, and committed on finalization
- Role-Based Access Control: Dev-mode auth system with four roles (Customer, Cashier, Manager, Admin) and 15 granular capabilities governing inventory CRUD, transaction processing, register management, and reporting access
- Flexible Sorting & Filtering: Multi-field priority sorting (quantity, category, species) with configurable sort order and optional out-of-stock hiding
- Complete CRUD Operations: Create, read, update, and delete inventory items with full strain lineage data
- GraphQL Inventory API: Inventory queries available via
/graphql/inventoryusingmorpheus-graphql(backend) andpurescript-graphql-client(frontend), scoped to read-only inventory access
- Full Transaction Lifecycle: Create → add items (with reservation) → add payments → finalize (commits inventory) or clear (releases reservations)
- Parallel Data Loading: The POS page loads inventory, initializes the register, and starts a transaction concurrently using the frontend's
parSequence_pattern; degrades gracefully toTxPageDegradedstate on partial load failure - Multiple Payment Methods: Cash, credit, debit, ACH, gift card, stored value, mixed, and custom payment types with change calculation
- Tax Management: Per-item tax records with category tracking (regular sales, excise, cannabis, local, medical)
- Discount Support: Percentage-based, fixed amount, BOGO, and custom discount types with approval tracking
- Automatic Total Recalculation: Server-side recalculation of subtotals, taxes, discounts, and totals on item/payment changes
- Cash Register Management: Open registers with starting cash, close with counted cash and automatic variance calculation
- Register Persistence: Register IDs stored in localStorage, auto-recovered on page load via get-or-create pattern
- Transaction Modifications: Void (marks existing transaction) and refund (creates inverse transaction with negated amounts) operations with reason tracking
- Payment Status Tracking: Transaction status auto-updates based on payment coverage (payments ≥ total → Completed)
- Customer Verification Types: Age verification, medical card, ID scan, visual inspection, patient registration, purchase limit check
- Compliance Records: Per-transaction compliance tracking with verification status, state reporting status, and reference IDs
- Reporting Stubs: Compliance and daily financial report endpoints defined with types — implementation pending
| Concern | Technology |
|---|---|
| Language | PureScript — strongly-typed FP compiling to JavaScript |
| UI | Deku — declarative, hooks-based rendering with Nut as the renderable type |
| State | FRP.Poll — reactive streams with create/push for mutable cells |
| Routing | Routing.Duplex + Routing.Hash — hash-based client-side routing |
| HTTP | purescript-fetch with Yoga.JSON for serialization |
| GraphQL | purescript-graphql-client with AffjaxWebClient — inventory queries via /graphql/inventory |
| Money | Data.Finance.Money — Discrete USD (integer cents) with formatting |
| Async | Effect.Aff with run helper, parSequence_, killFiber for route-driven loading |
| Parallelism | Control.Parallel — concurrent data fetching within a single route |
| Concern | Technology |
|---|---|
| Language | Haskell |
| API | Servant — type-level REST API definitions |
| GraphQL | morpheus-graphql — inventory-scoped GraphQL resolver at /graphql/inventory |
| Database | postgresql-simple with sql quasiquoter, resource-pool for connection management |
| Server | Warp + warp-tls — HTTPS via TLS 1.2+ with mkcert certs in development |
| JSON | Aeson (derived + manual instances) |
| Auth | Dev-mode X-User-Id header lookup with role-based capabilities |
| Concern | Technology |
|---|---|
| Database | PostgreSQL with reservation-based inventory, cascading deletes, parameterized queries |
| Dev Environment | Nix flakes — reproducible builds, per-machine dev shells |
| Secrets | sops + age — encrypted secrets/cheeblr.yaml, key derived from SSH ed25519 key |
| TLS | mkcert for local dev certs; warp-tls for HTTPS on the backend; Vite HTTPS config |
| Build (Haskell) | Cabal via haskell.nix / CHaP |
| Build (PureScript) | Spago |
| Testing | Haskell unit + integration tests; 484 PureScript tests; ephemeral-PostgreSQL integration harness |
- Nix with flakes enabled
git clone https://github.com/harryprayiv/cheeblr.git
cd cheeblr
nix develop
# First-time: set up secrets and TLS
sops-init-key # derive age key from ~/.ssh/id_ed25519
sops-bootstrap # create secrets/cheeblr.yaml with a random DB password
tls-setup # generate mkcert dev certs for localhost
tls-sops-update # encrypt certs into secrets/cheeblr.yaml
sops-status # verify everything is wired up
# Start everything
pg-start
deploy # tmux session: backend (HTTPS :8080) + frontend (HTTPS :5173) + pg-statsSee Nix Development Environment for the full command reference, individual service scripts (backend-start, frontend-start, etc.), and the test suite.
| Method | Endpoint | Description |
|---|---|---|
| GET | /session |
Current user capabilities (separated from inventory payload) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /inventory |
All items with available quantities |
| POST | /inventory |
Create item (Manager+) |
| PUT | /inventory |
Update item (Cashier+) |
| DELETE | /inventory/:sku |
Delete item (Manager+) |
| GET | /inventory/available/:sku |
Real-time availability (total, reserved, actual) |
| POST | /inventory/reserve |
Reserve inventory for a transaction |
| DELETE | /inventory/release/:id |
Release a reservation |
| POST | /graphql/inventory |
GraphQL endpoint — inventory queries (read-only) |
| Method | Endpoint | Description |
|---|---|---|
| GET | /transaction |
List all transactions |
| GET | /transaction/:id |
Get transaction with items and payments |
| POST | /transaction |
Create transaction |
| PUT | /transaction/:id |
Update transaction |
| POST | /transaction/void/:id |
Void with reason |
| POST | /transaction/refund/:id |
Create inverse refund transaction |
| POST | /transaction/item |
Add item (checks availability, creates reservation) |
| DELETE | /transaction/item/:id |
Remove item (releases reservation) |
| POST | /transaction/payment |
Add payment |
| DELETE | /transaction/payment/:id |
Remove payment |
| POST | /transaction/finalize/:id |
Finalize (commits inventory, completes reservations) |
| POST | /transaction/clear/:id |
Clear all items/payments, release reservations |
| Method | Endpoint | Description |
|---|---|---|
| GET | /register |
List registers |
| GET | /register/:id |
Get register |
| POST | /register |
Create register |
| POST | /register/open/:id |
Open with starting cash |
| POST | /register/close/:id |
Close with counted cash, returns variance |
The frontend follows a centralized async loading pattern:
Main.pursowns all async data fetching, route matching, and fiber lifecycle management- Pages are pure renderers:
Poll Status → Nut— no side effects, nolaunchAff_, noPoll.create - Route changes cancel in-flight loading via
killFiberon the previous fiber parSequence_runs multiple loaders in parallel per route- Status ADTs per page (
Loading | Ready data | Error msg | Degraded partialData) provide type-safe loading states pure Loading <|> pollensures pages always start with a loading stateTxPageDegradedallows the transaction page to render with partial data when non-critical loads fail
Main.purs (orchestration)
├── Route matcher → killFiber prev → parSequence_ loaders → build page Nut
├── Loading functions: loadInventoryStatus, loadEditItem, loadDeleteItem, loadTxPageData
└── Callback-to-Aff wrappers (makeAff) for RegisterService integration
Pages/ (pure renderers)
├── LiveView: Poll InventoryLoadStatus → Nut
├── EditItem: Poll EditItemStatus → Nut
├── DeleteItem: Poll DeleteItemStatus → Nut
├── CreateTransaction: Poll TxPageStatus → Nut (parallel: inventory + register + tx; degrades gracefully)
├── CreateItem: UserId → String → Nut
└── TransactionHistory: Nut (placeholder)
App.hs (bootstrap, CORS, TLS middleware, warp-tls)
├── Server.hs (inventory + session handlers with capability checks)
├── Server/GraphQL.hs (morpheus-graphql resolver, /graphql/inventory)
├── Server/Transaction.hs (POS: transactions, registers, ledger, compliance)
├── Auth/Simple.hs (dev auth: X-User-Id → role → capabilities)
├── DB/Database.hs (inventory CRUD, connection pooling)
├── DB/Transaction.hs (transactions, reservations, registers, payments)
└── Types/ (domain models with Aeson + postgresql-simple instances)
InventoryResponse— plain array newtype of inventory items (capabilities separated)MutationResponse— uniform wrapper for all write operations (success/failure + message)- Session endpoint — user capabilities delivered independently of inventory data
- Item Selection: User adds item → backend checks
quantity - reserved→ creates reservation → returns item - Cart Management: Items tracked via transaction items table → server recalculates totals on each change
- Payment Processing: Payments added → server checks if payments ≥ total → auto-updates status
- Finalization:
menu_items.quantitydecremented → reservations markedCompleted→ transactionCOMPLETED - Cancellation:
POST /transaction/clear/:id→ reservationsReleased→ items/payments deleted → totals zeroed
| Table | Purpose |
|---|---|
menu_items |
Product catalog with stock quantities |
strain_lineage |
Cannabis-specific attributes (THC, terpenes, lineage) — FK to menu_items |
transaction |
Transaction records with status, totals, void/refund tracking |
transaction_item |
Line items — FK to transaction with CASCADE |
transaction_tax |
Per-item tax records — FK to transaction_item with CASCADE |
discount |
Discounts on items or transactions — FK with CASCADE |
payment_transaction |
Payment records — FK to transaction with CASCADE |
inventory_reservation |
Reservation tracking (Reserved → Completed/Released) |
register |
Cash register state and history |
- TLS everywhere: backend runs warp-tls; frontend Vite dev server configured for HTTPS; all service scripts inject
USE_TLS,TLS_CERT_FILE,TLS_KEY_FILEfrom sops - Parameterized queries throughout — no string interpolation in SQL
- Type safety across the full stack — shared domain types between PureScript and Haskell enforce JSON contract at compile time (contract tests catch serialization divergence)
- Role-based capabilities — 15 granular permissions mapped to 4 roles, enforced on inventory writes
- Input validation — frontend (ValidationRule combinators) and backend (type-level constraints via Servant)
- Secrets management — database password and TLS cert/key stored in sops-encrypted
secrets/cheeblr.yaml; never in plaintext on disk - Audit trail — transactions track void/refund reasons, reference transactions, and modification timestamps
Current limitation: Authentication is dev-mode only (X-User-Id header with fixed users). See Security Recommendations for the planned upgrade path to libsodium public-key challenge-response.
test-unit # Haskell unit tests + 484 PureScript tests (no services needed)
test-integration # ephemeral PostgreSQL + backend on :18080, HTTP integration suite
test-integration-tls # same as above with TLS; validates cert SAN and plain-HTTP rejection
test-suite # all three phases in sequence
test-smoke # hit live backend on :8080, check endpoint and JSON contract healthIntegration tests spin up and tear down their own isolated PostgreSQL instance so they can run independently of pg-start.
- Full inventory CRUD with strain lineage
- Inventory reservation system (reserve on cart add, release on remove, commit on finalize)
- Complete transaction lifecycle (create → items → payments → finalize/void/refund/clear)
- Multiple payment methods with change calculation
- Cash register open/close with variance tracking
- Tax and discount record management
- Role-based capability system (4 roles, 15 capabilities)
- Dev auth with
X-User-Idheader and user switcher widget - Centralized async loading with fiber cancellation on route change
- Parallel data loading for POS page (inventory + register + transaction)
TxPageDegradedstate for resilient POS page loadingMutationResponseuniform write response type/sessionendpoint — user capabilities separated from inventory payload- TLS/HTTPS via warp-tls + mkcert; all service scripts TLS-aware
- sops secrets management (DB password + TLS certs)
- GraphQL inventory API (
/graphql/inventory) via morpheus-graphql + purescript-graphql-client - Comprehensive test suite (Haskell unit + integration, 484 PureScript tests, ephemeral-DB harness, TLS wire checks)
- JSON contract tests between PureScript and Haskell catching serialization divergence
- Daily financial reporting (endpoints exist, implementation pending)
- Compliance verification system (types and stubs defined)
- GraphQL WebSocket subscriptions for live inventory via PostgreSQL
LISTEN/NOTIFY
- Real authentication (libsodium public-key challenge-response, replacing dev
X-User-Id) - Capability enforcement on transaction/register endpoints
- Inventory reservation expiry (cleanup of abandoned reservations)
- Transaction history page (currently a placeholder)
- Advanced reporting and analytics
- Multi-location support
- Third-party integrations (Metrc, Leafly)
This project is licensed under the GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 — see the LICENSE file for details.
Contributions are welcome. Please feel free to submit a Pull Request.
If you like my style, Cardano ADA donations fund continued work.
