A multi-tenant point-of-sale platform
A point-of-sale product for small shops — a checkout app that keeps selling offline, an owner dashboard, a platform-admin console and subscription billing — built and deployed end to end.
The challenge
Small shops need to ring up sales, track stock and close shifts on whatever device is at the counter, and they cannot stop selling when the connection drops. Each shop's data must stay isolated on one shared backend, and the platform has to bill shops for their plans.
My contribution
Sole developer. I designed and built the Angular checkout and admin apps, the NestJS API, the PostgreSQL schema, the realtime and offline sync, the billing and payment integrations, the CI pipeline and the EC2 deployment.
The approach
An Nx monorepo holds three apps: an Angular point-of-sale web app with a service worker and IndexedDB, an Angular platform-admin console, and a NestJS API on Fastify with Prisma over PostgreSQL. Tenancy is enforced in middleware that derives the shop from the access token, while the platform-admin plane uses a separate secret and never gets a shop context. Redis backs BullMQ jobs for work such as invoice renewal. Sales events reach open devices over WebSockets. Production runs on EC2 behind nginx with PM2, TLS from Let's Encrypt, and PostgreSQL on RDS.
The outcome
The product covers checkout, returns, inventory reservation, shelf-life lots, shifts, sales analytics, receipts, store settings, staff, VietQR and SePay payments, plan subscriptions with auto-renewal, and a platform-admin console. CI lints, tests and builds affected projects, enforces a bundle budget, scans for CVEs with Trivy and runs Playwright end-to-end tests.
Context
A point-of-sale product for small shops, built alone across about 430 commits. It has four surfaces: the checkout app used at the counter, the owner's reports and settings, a platform-admin console for managing shops, and billing for the shops' subscription plans.
Architecture
- Web (Angular): checkout, orders, returns, catalog, shifts, reports and settings, built with PrimeNG and NgRx Signals. A service worker and IndexedDB (Dexie) keep it working offline, and Sentry records errors.
- Admin (Angular): a separate console for platform operators.
- API (NestJS on Fastify): modules for sales, inventory, catalog, shifts, analytics, receipts (PDF and QR), staff, billing, VietQR payments, onboarding and platform administration. It uses Prisma over PostgreSQL, and Redis with BullMQ for background jobs.
- Delivery: an Nx monorepo with GitHub Actions CI. It deploys to EC2 behind nginx with PM2, uses PostgreSQL on RDS, and has runbooks for backup and restore, maintenance mode and billing rollouts.
A problem I solved: offline sales and the wrong shop
Sales taken offline are queued in IndexedDB and replayed when the connection returns. The queue lives on the device, but a sale belongs to a shop, and the sync endpoint takes the shop from the bearer token. If a different account signed in on the same device, replaying the queue would file the first shop's sales under the second.
The fix filters every queue read by the signed-in shop. Rows belonging to other shops stay in place for their own account to sync later, instead of being dropped or misfiled. A related bug: the queue's re-entrancy guard was a reactive signal, so any reactive caller picked it up as a dependency. Writing to it re-triggered the caller, which re-entered the drain loop forever. The guard became a plain field, since nothing outside the service ever read it.
Validation
CI runs affected lint, unit tests with coverage and builds, a production bundle budget audit, an OpenAPI drift check, a Trivy CVE scan, and Playwright end-to-end tests. API integration tests use Testcontainers for PostgreSQL and Redis.