39 Commits

Author SHA1 Message Date
ryan
8256380cd1 feat: add cash rounding, POS test suite, and fix test harness port cleanup
All checks were successful
CI / ci (pull_request) Successful in 20s
CI / e2e (pull_request) Successful in 50s
- Add Swedish rounding (nearest nickel) for cash payments at locations with cash_rounding enabled
- Add rounding_adjustment column to transactions, cash_rounding to locations
- Add POS schema to database plugin for relational query support
- Complete/void routes now return full transaction with line items via getById
- Test harness killPort falls back to fuser when lsof unavailable (fixes stale process bug)
- Add 35-test POS API suite covering discounts, drawer, transactions, tax, rounding, e2e flow
- Add unit tests for tax service and POS Zod schemas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:23:05 +00:00
ryan
7b15f18e59 feat: add core POS module — transactions, discounts, drawer, tax
Phase 3a backend API for point-of-sale. Includes:

Schema (packages/backend/src/db/schema/pos.ts):
- pgEnums: transaction_type, transaction_status, payment_method,
  discount_type, discount_applies_to, drawer_status
- Tables: transaction, transaction_line_item, discount,
  discount_audit, drawer_session
- Transaction links to accounts, repair_tickets, repair_batches
- Line items link to products and inventory_units

Tax system:
- tax_rate + service_tax_rate columns on location
- tax_category enum (goods/service/exempt) on product
- Tax resolves per line item: goods→tax_rate, service→service_tax_rate,
  exempt→0. Repair line items map: part→goods, labor→service
- GET /tax/lookup/:zip stubbed for future API integration (TAX_API_KEY)

Services (export const pattern, matching existing codebase):
- TransactionService: create, addLineItem, removeLineItem, applyDiscount,
  recalculateTotals, complete (decrements inventory), void, getReceipt
- DiscountService: CRUD + listAll for dropdowns
- DrawerService: open/close with expected balance + over/short calc
- TaxService: getRateForLocation (by tax category), calculateTax

Routes:
- POST/GET /transactions, GET /transactions/:id, GET /transactions/:id/receipt
- POST /transactions/:id/line-items, DELETE /transactions/:id/line-items/:id
- POST /transactions/:id/discounts, /complete, /void
- POST /drawer/open, POST /drawer/:id/close, GET /drawer/current, GET /drawer
- CRUD /discounts + GET /discounts/all
- GET /products/lookup/upc/:upc (barcode scanner support)

All routes gated by pos.view/pos.edit/pos.admin + withModule('pos').
POS module already seeded in migration 0026.

Still needed: bun install, drizzle-kit generate + migrate, tests, lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:26:38 +00:00
Ryan Moon
7aa81c4e7c docs: add infrastructure, build pipeline, and dev box sections to CLAUDE.md
All checks were successful
Build & Release / build (push) Successful in 15s
2026-04-04 10:30:40 -05:00
Ryan Moon
b318345fdf fix: use haproxy to strip PROXY protocol before sshd — nginx sends PROXY headers on all TCP
All checks were successful
Build Devpod / build (push) Successful in 3m0s
Build & Release / build (push) Successful in 17s
2026-04-04 10:04:42 -05:00
Ryan Moon
cc5ab41da4 fix: enable PermitRootLogin for SSH key access
All checks were successful
Build Devpod / build (push) Successful in 8s
Build & Release / build (push) Successful in 16s
2026-04-04 09:49:29 -05:00
Ryan Moon
c54069ad99 fix: switch code-server to no-auth, Cloudflare Access handles authentication
All checks were successful
Build Devpod / build (push) Successful in 9s
Build & Release / build (push) Successful in 16s
2026-04-04 09:10:48 -05:00
Ryan Moon
04420dbd12 fix: push versioned devpod tag per build to avoid DOCR tag caching
All checks were successful
Build & Release / build (push) Successful in 18s
2026-04-04 09:04:55 -05:00
Ryan Moon
f538c60f3d fix: bootstrap .profile and .gitconfig on fresh PVC
All checks were successful
Build Devpod / build (push) Successful in 8s
Build & Release / build (push) Successful in 16s
2026-04-04 08:56:20 -05:00
Ryan Moon
1524153cfb fix: bootstrap .bashrc and PATH on fresh PVC mount
All checks were successful
Build Devpod / build (push) Successful in 7s
Build & Release / build (push) Successful in 15s
2026-04-04 08:55:26 -05:00
Ryan Moon
4c31357428 fix: create .ssh dir before writing authorized_keys
All checks were successful
Build Devpod / build (push) Successful in 9s
Build & Release / build (push) Successful in 16s
2026-04-04 08:54:52 -05:00
Ryan Moon
b3e67d1483 fix: set workdir and code-server root to /root for persistent home
All checks were successful
Build Devpod / build (push) Successful in 19s
Build & Release / build (push) Successful in 16s
2026-04-04 08:43:36 -05:00
Ryan Moon
3d081ee01f fix: push devpod image to manager repo as devpod-latest tag
All checks were successful
Build & Release / build (push) Successful in 18s
2026-04-04 08:36:08 -05:00
Ryan Moon
9942a5638f feat: add psql, redis-cli, helm, k9s to devpod image
Some checks failed
Build & Release / build (push) Failing after 8s
Build Devpod / build (push) Failing after 6s
2026-04-04 07:14:12 -05:00
Ryan Moon
b40bab0391 feat: add devpod image — code-server, Claude Code, bun, kubectl
Some checks failed
Build & Release / build (push) Successful in 22s
Build Devpod / build (push) Failing after 5s
2026-04-04 06:56:56 -05:00
Ryan Moon
de70fb47f9 fix: switch from compiled bun binary to bun run to fix Fastify plugin name crash
All checks were successful
Build & Release / build (push) Successful in 1m11s
2026-04-03 21:52:50 -05:00
Ryan Moon
c5d618698b fix: remove untagged manifest cleanup, deletes tagged chart by shared digest
All checks were successful
Build & Release / build (push) Successful in 26s
2026-04-03 21:46:41 -05:00
Ryan Moon
54943ea1b7 fix: clean up untagged helm OCI manifests after chart push
All checks were successful
Build & Release / build (push) Successful in 30s
2026-04-03 21:40:21 -05:00
Ryan Moon
ba1c385937 fix: simplify CI to use run number for versioning, remove commit-back step
All checks were successful
Build & Release / build (push) Successful in 28s
2026-04-03 21:36:23 -05:00
lunarfront-bot
2231f06234 chore: bump version to v0.1.1
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 02:31:04 +00:00
Ryan Moon
fe29e548fb revert: remove incorrect /api ingress rule, frontend nginx handles /v1 proxy
Some checks failed
Build & Release / build (push) Has been cancelled
2026-04-03 21:29:19 -05:00
Ryan Moon
3d72f04b14 fix: add /api ingress path routing to backend 2026-04-03 21:29:19 -05:00
lunarfront-bot
36eb377583 chore: bump version to v0.1.0
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 01:08:32 +00:00
Ryan Moon
b8f0c7ecba feat: add Spaces env vars to backend deployment
All checks were successful
Build & Release / build (push) Successful in 18s
2026-04-03 20:07:32 -05:00
lunarfront-bot
0034e0b8b3 chore: bump version to v0.0.29
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 00:49:46 +00:00
Ryan Moon
d9a7409f9c chore: remove valkey chart templates
Some checks failed
Build & Release / build (push) Has been cancelled
2026-04-03 19:48:48 -05:00
Ryan Moon
358e07b1d5 feat: remove per-customer valkey, use managed Valkey with REDIS_KEY_PREFIX 2026-04-03 19:48:48 -05:00
lunarfront-bot
5df914a40f chore: bump version to v0.0.28
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 00:47:43 +00:00
Ryan Moon
1f8002629f fix: add libstdc++ to runtime image for bun compiled binary
All checks were successful
Build & Release / build (push) Successful in 1m52s
2026-04-03 19:45:48 -05:00
lunarfront-bot
ff2e4586f3 chore: bump version to v0.0.27
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 00:36:04 +00:00
Ryan Moon
019867f1fa fix: update GIT_REMOTE to git.lunarfront.tech
Some checks failed
Build & Release / build (push) Has been cancelled
2026-04-03 19:35:45 -05:00
Ryan Moon
48d49a068a fix: include chart files in version bump commit to prevent rebase conflict
Some checks failed
Build & Release / build (push) Failing after 16s
2026-04-03 19:34:31 -05:00
11f81cfd8e Merge pull request 'feat: add Helm chart and switch image builds to DOCR' (#3) from fix/ci-only-on-pr into main
Some checks failed
Build & Release / build (push) Failing after 16s
Reviewed-on: #3
2026-04-03 23:56:55 +00:00
Ryan Moon
1601e0f849 feat: add Helm chart and switch image builds to DOCR
All checks were successful
CI / ci (pull_request) Successful in 18s
CI / e2e (pull_request) Successful in 1m1s
- Add chart/ with backend, frontend, valkey deployments, services, and ingress
- Update nginx.conf to use BACKEND_URL env var via envsubst
- Update Dockerfile.frontend to use nginx template mechanism
- Build.yml: switch Docker registry from Gitea to DOCR, add helm package+push step
2026-04-03 18:53:47 -05:00
lunarfront-bot
0e3a8d7504 chore: bump version to v0.0.26
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-03 01:36:38 +00:00
Ryan Moon
87456e3aac fix: add DOCKER_HOST to build job env
Some checks failed
Build & Release / build (push) Has been cancelled
2026-04-02 20:28:27 -05:00
d7249088e9 Merge pull request 'fix: use REGISTRY and GIT_REMOTE vars, point to git2.lunarfront.tech' (#2) from fix/ci-only-on-pr into main
Some checks failed
Build & Release / build (push) Failing after 8s
Reviewed-on: https://git2.lunarfront.tech/ryan/lunarfront-app/pulls/2
2026-04-03 01:27:02 +00:00
Ryan Moon
59ea9557d1 fix: use REGISTRY and GIT_REMOTE vars, point to git2.lunarfront.tech
All checks were successful
CI / ci (pull_request) Successful in 1m27s
CI / e2e (pull_request) Successful in 1m18s
2026-04-02 19:50:53 -05:00
628c090dfd Merge pull request 'fix/ci-only-on-pr' (#1) from fix/ci-only-on-pr into main
Some checks failed
Build & Release / build (push) Failing after 7s
Reviewed-on: https://git2.lunarfront.tech/ryan/lunarfront-app/pulls/1
2026-04-03 00:47:43 +00:00
Ryan Moon
9fc42b7881 fix: set DOCKER_HOST for e2e job to use dind TCP endpoint
All checks were successful
CI / ci (pull_request) Successful in 29s
CI / e2e (pull_request) Successful in 58s
2026-04-02 19:09:30 -05:00
45 changed files with 3004 additions and 91 deletions

View File

@@ -0,0 +1,34 @@
name: Build Devpod
on:
push:
branches: [main]
paths:
- Dockerfile.devpod
- entrypoint-devpod.sh
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
REGISTRY: registry.digitalocean.com/lunarfront
DOCKER_HOST: tcp://localhost:2375
VERSION: devpod-0.1.${{ github.run_number }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to DOCR
run: echo "${{ secrets.DOCR_TOKEN }}" | docker login registry.digitalocean.com -u token --password-stdin
- name: Build and push devpod
run: |
docker build \
-t $REGISTRY/manager:$VERSION \
-t $REGISTRY/manager:devpod-latest \
-f Dockerfile.devpod .
docker push $REGISTRY/manager:$VERSION
docker push $REGISTRY/manager:devpod-latest

View File

@@ -12,83 +12,55 @@ concurrency:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "!startsWith(github.event.head_commit.message, 'chore: bump version')" env:
REGISTRY: registry.digitalocean.com/lunarfront
DOCKER_HOST: tcp://localhost:2375
VERSION: 0.1.${{ github.run_number }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.BOT_TOKEN }}
- name: Determine version bump - name: Login to DOCR
id: bump run: echo "${{ secrets.DOCR_TOKEN }}" | docker login registry.digitalocean.com -u token --password-stdin
run: |
COMMIT_MSG=$(git log -1 --pretty=%s)
if echo "$COMMIT_MSG" | grep -qiE "^breaking(\(.+\))?:|^.+!:"; then
echo "type=major" >> $GITHUB_OUTPUT
elif echo "$COMMIT_MSG" | grep -qiE "^feat(\(.+\))?:"; then
echo "type=minor" >> $GITHUB_OUTPUT
else
echo "type=patch" >> $GITHUB_OUTPUT
fi
- name: Bump version in package.json
id: version
run: |
VERSION=$(node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('packages/backend/package.json', 'utf8'));
const [major, minor, patch] = pkg.version.split('.').map(Number);
const type = '${{ steps.bump.outputs.type }}';
if (type === 'major') pkg.version = \`\${major + 1}.0.0\`;
else if (type === 'minor') pkg.version = \`\${major}.\${minor + 1}.0\`;
else pkg.version = \`\${major}.\${minor}.\${patch + 1}\`;
fs.writeFileSync('packages/backend/package.json', JSON.stringify(pkg, null, 2) + '\n');
console.log(pkg.version);
")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login to registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.lunarfront.tech -u ryan --password-stdin
- name: Build and push backend - name: Build and push backend
run: | run: |
VERSION=${{ steps.version.outputs.version }}
SHA=$(git rev-parse --short HEAD) SHA=$(git rev-parse --short HEAD)
docker build \ docker build \
--build-arg APP_VERSION=$VERSION \ --build-arg APP_VERSION=$VERSION \
-t registry.lunarfront.tech/ryan/lunarfront-app:$VERSION \ -t $REGISTRY/lunarfront-app:$VERSION \
-t registry.lunarfront.tech/ryan/lunarfront-app:$SHA \ -t $REGISTRY/lunarfront-app:$SHA \
-t registry.lunarfront.tech/ryan/lunarfront-app:latest \ -t $REGISTRY/lunarfront-app:latest \
-f Dockerfile . -f Dockerfile .
docker push registry.lunarfront.tech/ryan/lunarfront-app:$VERSION docker push $REGISTRY/lunarfront-app:$VERSION
docker push registry.lunarfront.tech/ryan/lunarfront-app:$SHA docker push $REGISTRY/lunarfront-app:$SHA
docker push registry.lunarfront.tech/ryan/lunarfront-app:latest docker push $REGISTRY/lunarfront-app:latest
- name: Build and push frontend - name: Build and push frontend
run: | run: |
VERSION=${{ steps.version.outputs.version }}
SHA=$(git rev-parse --short HEAD) SHA=$(git rev-parse --short HEAD)
docker build \ docker build \
-t registry.lunarfront.tech/ryan/lunarfront-frontend:$VERSION \ -t $REGISTRY/lunarfront-frontend:$VERSION \
-t registry.lunarfront.tech/ryan/lunarfront-frontend:$SHA \ -t $REGISTRY/lunarfront-frontend:$SHA \
-t registry.lunarfront.tech/ryan/lunarfront-frontend:latest \ -t $REGISTRY/lunarfront-frontend:latest \
-f Dockerfile.frontend . -f Dockerfile.frontend .
docker push registry.lunarfront.tech/ryan/lunarfront-frontend:$VERSION docker push $REGISTRY/lunarfront-frontend:$VERSION
docker push registry.lunarfront.tech/ryan/lunarfront-frontend:$SHA docker push $REGISTRY/lunarfront-frontend:$SHA
docker push registry.lunarfront.tech/ryan/lunarfront-frontend:latest docker push $REGISTRY/lunarfront-frontend:latest
- name: Commit version bump - name: Install Helm
run: curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
- name: Package and push Helm chart
run: | run: |
git config user.name "lunarfront-bot" sed -i "s/^version:.*/version: $VERSION/" chart/Chart.yaml
git config user.email "bot@lunarfront.tech" sed -i "s/^appVersion:.*/appVersion: \"$VERSION\"/" chart/Chart.yaml
git remote set-url origin https://lunarfront-bot:${{ secrets.BOT_TOKEN }}@git.lunarfront.tech/ryan/lunarfront-app.git sed -i "s|tag: .*|tag: $VERSION|g" chart/values.yaml
git add packages/backend/package.json helm registry login registry.digitalocean.com -u token --password "${{ secrets.DOCR_TOKEN }}"
git commit -m "chore: bump version to v${{ steps.version.outputs.version }}" helm package chart/
git pull --rebase origin main helm push lunarfront-$VERSION.tgz oci://registry.digitalocean.com/lunarfront
git push origin main
- name: Logout - name: Logout
if: always() if: always()
run: docker logout registry.lunarfront.tech run: docker logout registry.digitalocean.com

View File

@@ -30,6 +30,8 @@ jobs:
e2e: e2e:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: ci needs: ci
env:
DOCKER_HOST: tcp://localhost:2375
steps: steps:
- name: Checkout - name: Checkout

112
CLAUDE.md
View File

@@ -63,3 +63,115 @@
- API routes are thin — validate with Zod, call a service, return result - API routes are thin — validate with Zod, call a service, return result
- All financial events must be auditable (append-only audit records) - All financial events must be auditable (append-only audit records)
- JSON structured logging with request IDs on every log line - JSON structured logging with request IDs on every log line
---
## Infrastructure
### Overview
LunarFront runs on DigitalOcean Kubernetes (DOKS). Each customer gets an isolated namespace, database, and Helm release managed by ArgoCD.
### Key Services
- **Cluster:** `lunarfront` DOKS cluster, region NYC
- **Registry:** `registry.digitalocean.com/lunarfront` (DOCR) — stores Docker images and Helm charts
- **Git:** `git.lunarfront.tech` — self-hosted Gitea, source of truth for code and charts
- **CI:** Gitea Actions — builds Docker images and Helm charts on push to `main`
- **CD:** ArgoCD at `argocd.lunarfront.tech` — deploys from `lunarfront-charts` repo
- **Database:** DO Managed PostgreSQL — one database per customer, plus manager DB
- **Cache/Queue:** DO Managed Valkey — shared across all customers (key-prefixed per customer)
- **Ingress:** nginx ingress controller with Cloudflare proxy in front
- **TLS:** cert-manager with Let's Encrypt (letsencrypt-prod cluster issuer)
- **DNS:** Cloudflare — wildcard `*.lunarfront.tech` → cluster LB IP `167.99.21.170`
### Node Pools
- `system` — 2x s-2vcpu-4gb, runs ingress, ArgoCD, manager, pgbouncer
- `customers` — autoscales 0→N, s-4vcpu-8gb, runs customer app pods (tainted `role=customer`)
- `dev` — autoscales 0→1, s-4vcpu-8gb, runs dev pod only (tainted `dedicated=dev:NoSchedule`)
### Repos
- `lunarfront-app` — main application code (this repo)
- `lunarfront-charts` — Helm charts and ArgoCD app definitions
- `lunarfront-infra` — Terraform for DO infrastructure (DOKS, managed DBs, registry, DNS)
- `lunarfront-manager` — internal ops tool for provisioning/deprovisioning customers
---
## Build & Deploy Pipeline
### How it works
1. Push code to `main` on `lunarfront-app`
2. Gitea Actions runs `.gitea/workflows/build.yml`:
- Builds `lunarfront-app` Docker image → pushes as `0.1.{run_number}`, `{sha}`, `latest`
- Builds `lunarfront-frontend` Docker image → same tags
- Packages Helm chart → pushes as `0.1.{run_number}` to DOCR OCI registry
3. ArgoCD image updater detects new image digests → updates customer deployments
4. New customer provisions always get the latest chart version (queried from DOCR at provision time)
5. Existing customers upgraded via `POST /customers/:slug/upgrade` or `POST /customers/upgrade-all` in the manager
### Versioning
- Version format: `0.1.{gitea_run_number}` — always incrementing, no git commit-back needed
- No version stored in git — source of truth is DOCR tags
- Chart version and app version are kept in sync
### Key files
- `Dockerfile` — backend image (bun runtime, runs `packages/backend/src/main.ts` directly)
- `Dockerfile.frontend` — frontend nginx image
- `chart/` — Helm chart for customer app deployments
- `.gitea/workflows/build.yml` — CI pipeline
- `.gitea/workflows/build-devpod.yml` — builds dev box image on Dockerfile.devpod changes
---
## Dev Box
### What it is
A persistent development pod running in the `dev` namespace on the cluster. Provides a full remote dev environment accessible from anywhere.
- **VS Code in browser:** `dev.lunarfront.tech` (Cloudflare Access protected, OTP to email)
- **SSH:** `ssh -p 2222 root@dev-ssh.lunarfront.tech`
- **Storage:** 100GB DO block storage PVC mounted at `/root` — everything in home dir persists
- **Image:** `registry.digitalocean.com/lunarfront/manager:devpod-latest`
- **Tools:** bun, Claude Code CLI, code-server, kubectl, helm, k9s, doctl, psql, redis-cli, git
### Managing the dev pod
```bash
# Scale up (provisions node automatically)
kubectl scale deployment dev -n dev --replicas=1
# Scale down (node auto-terminates after ~15 min)
kubectl scale deployment dev -n dev --replicas=0
```
### Running the app locally on the dev box (no containers)
The dev box runs the app as plain Bun processes, connecting to the same DO managed services as production.
**Required env vars** (create a `.env` file in the repo root or export in `.bashrc`):
```bash
DATABASE_URL=postgresql://... # DO managed postgres, lunarfront database
REDIS_URL=rediss://... # DO managed valkey
JWT_SECRET=... # any random hex string for local dev
PORT=8000
```
**Start the app:**
```bash
cd ~/lunarfront-app
bun run dev
```
Access the running backend at `dev.lunarfront.tech/proxy/8000/` in the browser (code-server proxy), or via SSH port forward:
```bash
ssh -p 2222 -L 8000:localhost:8000 root@dev-ssh.lunarfront.tech
```
**Run migrations against the dev database:**
```bash
bunx drizzle-kit migrate
```
### Workflow
1. Edit code in VS Code at `dev.lunarfront.tech` or via SSH
2. Run and test locally with `bun run dev` — app connects to DO managed postgres/valkey
3. Push to `main` → Gitea Actions builds and pushes new Docker image + Helm chart
4. ArgoCD deploys to the cluster automatically
5. Use manager at `manager.lunarfront.tech` to upgrade customer instances if needed

View File

@@ -6,8 +6,9 @@ COPY packages/backend/package.json packages/backend/
COPY packages/admin/package.json packages/admin/ COPY packages/admin/package.json packages/admin/
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
FROM oven/bun:1.3.11-alpine AS build FROM oven/bun:1.3.11-alpine
ARG APP_VERSION=dev ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
@@ -16,18 +17,11 @@ COPY packages/shared ./packages/shared
COPY packages/backend ./packages/backend COPY packages/backend ./packages/backend
COPY package.json ./ COPY package.json ./
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
WORKDIR /app/packages/backend
RUN bun build src/main.ts --compile --outfile /app/server \
--define "process.env.APP_VERSION='${APP_VERSION}'"
FROM alpine:3.21
RUN addgroup -S app && adduser -S app -G app RUN addgroup -S app && adduser -S app -G app
WORKDIR /app COPY packages/backend/src/db/migrations ./migrations
COPY --from=build /app/server ./server
COPY --from=build /app/packages/backend/src/db/migrations ./migrations
ENV MIGRATIONS_DIR=/app/migrations ENV MIGRATIONS_DIR=/app/migrations
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:8000/v1/health || exit 1 CMD wget -qO- http://localhost:8000/v1/health || exit 1
USER app USER app
CMD ["./server"] CMD ["bun", "run", "packages/backend/src/main.ts"]

46
Dockerfile.devpod Normal file
View File

@@ -0,0 +1,46 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV HOME=/root
ENV PATH="/root/.bun/bin:$PATH"
# Base tools
RUN apt-get update && apt-get install -y --no-install-recommends \
curl wget git openssh-server ca-certificates gnupg \
build-essential unzip jq tmux zsh ripgrep \
postgresql-client redis-tools haproxy \
&& rm -rf /var/lib/apt/lists/*
# Bun
RUN curl -fsSL https://bun.sh/install | bash
# Claude Code CLI
RUN curl -fsSL https://claude.ai/install.sh | bash
# code-server (VS Code in browser)
RUN curl -fsSL https://code-server.dev/install.sh | sh
# kubectl
RUN curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \
-o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl
# doctl
RUN curl -fsSL https://github.com/digitalocean/doctl/releases/download/v1.119.0/doctl-1.119.0-linux-amd64.tar.gz \
| tar xz -C /usr/local/bin
# helm
RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# k9s
RUN curl -fsSL https://github.com/derailed/k9s/releases/latest/download/k9s_Linux_amd64.tar.gz \
| tar xz -C /usr/local/bin k9s
# SSH setup — host keys generated at runtime via entrypoint
RUN mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh
COPY entrypoint-devpod.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
WORKDIR /root
EXPOSE 8080 22
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -20,6 +20,8 @@ RUN bun run build
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/packages/admin/dist /usr/share/nginx/html COPY --from=build /app/packages/admin/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf # nginx docker image processes templates in /etc/nginx/templates/ with envsubst at startup
COPY nginx.conf /etc/nginx/templates/default.conf.template
ENV BACKEND_URL=http://localhost:8000
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

6
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: lunarfront
description: LunarFront small business management platform
type: application
version: 0.1.1
appVersion: "0.1.1"

View File

@@ -0,0 +1,8 @@
{{- define "lunarfront.name" -}}
{{- .Release.Name }}
{{- end }}
{{- define "lunarfront.labels" -}}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

View File

@@ -0,0 +1,89 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-backend
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ .Release.Name }}-backend
template:
metadata:
labels:
app: {{ .Release.Name }}-backend
spec:
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
containers:
- name: backend
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
ports:
- containerPort: {{ .Values.backend.port }}
env:
- name: PORT
value: {{ .Values.backend.port | quote }}
- name: NODE_ENV
value: production
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: redis-url
- name: REDIS_KEY_PREFIX
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: redis-key-prefix
- name: SPACES_KEY
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-key
- name: SPACES_SECRET
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-secret
- name: SPACES_BUCKET
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-bucket
- name: SPACES_ENDPOINT
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-endpoint
- name: SPACES_PREFIX
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-prefix
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: jwt-secret
livenessProbe:
httpGet:
path: /v1/health
port: {{ .Values.backend.port }}
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
httpGet:
path: /v1/health
port: {{ .Values.backend.port }}
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-backend
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
spec:
selector:
app: {{ .Release.Name }}-backend
ports:
- port: {{ .Values.backend.port }}
targetPort: {{ .Values.backend.port }}

View File

@@ -0,0 +1,30 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-frontend
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ .Release.Name }}-frontend
template:
metadata:
labels:
app: {{ .Release.Name }}-frontend
spec:
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
containers:
- name: frontend
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
ports:
- containerPort: {{ .Values.frontend.port }}
env:
- name: BACKEND_URL
value: "http://{{ .Release.Name }}-backend:{{ .Values.backend.port }}"
resources:
{{- toYaml .Values.frontend.resources | nindent 12 }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-frontend
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
spec:
selector:
app: {{ .Release.Name }}-frontend
ports:
- port: 80
targetPort: {{ .Values.frontend.port }}

View File

@@ -0,0 +1,29 @@
{{- if .Values.ingress.host }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
annotations:
kubernetes.io/ingress.class: {{ .Values.ingress.className }}
cert-manager.io/cluster-issuer: {{ .Values.ingress.tlsIssuer }}
spec:
ingressClassName: {{ .Values.ingress.className }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Release.Name }}-tls
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-frontend
port:
number: 80
{{- end }}

39
chart/values.yaml Normal file
View File

@@ -0,0 +1,39 @@
backend:
image:
repository: registry.digitalocean.com/lunarfront/lunarfront-app
tag: 0.0.27
pullPolicy: Always
port: 8000
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
frontend:
image:
repository: registry.digitalocean.com/lunarfront/lunarfront-frontend
tag: 0.0.27
pullPolicy: Always
port: 80
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
ingress:
host: ""
className: nginx
tlsIssuer: letsencrypt
imagePullSecrets:
- name: registry-lunarfront
# Secrets are expected to exist in-namespace as 'lunarfront-secrets' with keys:
# database-url, jwt-secret, redis-url
# These are created by the manager during provisioning, not by this chart.

77
entrypoint-devpod.sh Normal file
View File

@@ -0,0 +1,77 @@
#!/bin/bash
set -e
# Generate SSH host keys if not present
ssh-keygen -A
# Write authorized keys from env if provided
if [ -n "$SSH_AUTHORIZED_KEYS" ]; then
mkdir -p /root/.ssh
chmod 700 /root/.ssh
echo "$SSH_AUTHORIZED_KEYS" > /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
fi
# Bootstrap home dir on fresh PVC
if [ ! -f /root/.bashrc ]; then
cp /etc/skel/.bashrc /root/.bashrc 2>/dev/null || true
cat >> /root/.bashrc <<'EOF'
export PATH="/root/.bun/bin:$PATH"
export HISTFILE=/root/.bash_history
export HISTSIZE=10000
EOF
fi
if [ ! -f /root/.profile ]; then
cat > /root/.profile <<'EOF'
export PATH="/root/.bun/bin:$PATH"
[ -f /root/.bashrc ] && . /root/.bashrc
EOF
fi
if [ ! -f /root/.gitconfig ]; then
cat > /root/.gitconfig <<'EOF'
[user]
name = ryan
email = ryan@lunartech.com
[init]
defaultBranch = main
[core]
editor = code --wait
EOF
fi
# Allow root login via SSH key, listen on internal port 2222
echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
echo "Port 2222" >> /etc/ssh/sshd_config
# Start SSH daemon on internal port 2222
/usr/sbin/sshd
# Start haproxy on port 22 to accept PROXY protocol from nginx and forward to sshd:2222
cat > /etc/haproxy/haproxy.cfg <<'EOF'
global
daemon
maxconn 256
defaults
mode tcp
timeout connect 5s
timeout client 60s
timeout server 60s
frontend ssh
bind *:22 accept-proxy
default_backend sshd
backend sshd
server local 127.0.0.1:2222
EOF
haproxy -f /etc/haproxy/haproxy.cfg
# Start code-server
exec code-server \
--bind-addr 0.0.0.0:8080 \
--auth none \
--disable-telemetry \
/root

View File

@@ -3,9 +3,9 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Proxy API and WebDAV to backend # Proxy API and WebDAV to backend — BACKEND_URL injected at runtime via envsubst
location /v1/ { location /v1/ {
proxy_pass http://localhost:8000; proxy_pass ${BACKEND_URL};
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@@ -14,7 +14,7 @@ server {
} }
location /webdav/ { location /webdav/ {
proxy_pass http://localhost:8000; proxy_pass ${BACKEND_URL};
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'bun:test'
import { TaxService } from '../../src/services/tax.service.js'
describe('TaxService.calculateTax', () => {
it('calculates tax on a simple amount', () => {
// 8.25% on $100
expect(TaxService.calculateTax(100, 0.0825)).toBe(8.25)
})
it('rounds to 2 decimal places', () => {
// 8.25% on $10.01 = 0.825825 → 0.83
expect(TaxService.calculateTax(10.01, 0.0825)).toBe(0.83)
})
it('returns 0 for zero rate', () => {
expect(TaxService.calculateTax(100, 0)).toBe(0)
})
it('returns 0 for zero amount', () => {
expect(TaxService.calculateTax(0, 0.0825)).toBe(0)
})
it('handles small amounts', () => {
// 8.25% on $0.99 = 0.081675 → 0.08
expect(TaxService.calculateTax(0.99, 0.0825)).toBe(0.08)
})
it('handles large amounts', () => {
// 8.25% on $9999.99 = 824.999175 → 825.00
expect(TaxService.calculateTax(9999.99, 0.0825)).toBe(825)
})
it('handles 5% service tax rate', () => {
// 5% on $60 = 3.00
expect(TaxService.calculateTax(60, 0.05)).toBe(3)
})
it('handles fractional cent rounding down', () => {
// 7% on $1.01 = 0.0707 → 0.07
expect(TaxService.calculateTax(1.01, 0.07)).toBe(0.07)
})
it('handles fractional cent rounding up', () => {
// 7% on $1.05 = 0.0735 → 0.07
expect(TaxService.calculateTax(1.05, 0.07)).toBe(0.07)
})
})
describe('TaxService.roundToNickel', () => {
it('rounds .01 down to .00', () => {
expect(TaxService.roundToNickel(10.01)).toBe(10.00)
})
it('rounds .02 down to .00', () => {
expect(TaxService.roundToNickel(10.02)).toBe(10.00)
})
it('rounds .03 up to .05', () => {
expect(TaxService.roundToNickel(10.03)).toBe(10.05)
})
it('rounds .04 up to .05', () => {
expect(TaxService.roundToNickel(10.04)).toBe(10.05)
})
it('keeps .05 as is', () => {
expect(TaxService.roundToNickel(10.05)).toBe(10.05)
})
it('rounds .06 down to .05', () => {
expect(TaxService.roundToNickel(10.06)).toBe(10.05)
})
it('rounds .07 down to .05', () => {
expect(TaxService.roundToNickel(10.07)).toBe(10.05)
})
it('rounds .08 up to .10', () => {
expect(TaxService.roundToNickel(10.08)).toBe(10.10)
})
it('rounds .09 up to .10', () => {
expect(TaxService.roundToNickel(10.09)).toBe(10.10)
})
it('keeps .00 as is', () => {
expect(TaxService.roundToNickel(10.00)).toBe(10.00)
})
it('keeps .10 as is', () => {
expect(TaxService.roundToNickel(10.10)).toBe(10.10)
})
it('handles zero', () => {
expect(TaxService.roundToNickel(0)).toBe(0)
})
})
describe('TaxService.repairItemTypeToTaxCategory', () => {
it('maps labor to service', () => {
expect(TaxService.repairItemTypeToTaxCategory('labor')).toBe('service')
})
it('maps part to goods', () => {
expect(TaxService.repairItemTypeToTaxCategory('part')).toBe('goods')
})
it('maps flat_rate to goods', () => {
expect(TaxService.repairItemTypeToTaxCategory('flat_rate')).toBe('goods')
})
it('maps misc to goods', () => {
expect(TaxService.repairItemTypeToTaxCategory('misc')).toBe('goods')
})
it('maps unknown type to goods (default)', () => {
expect(TaxService.repairItemTypeToTaxCategory('something_else')).toBe('goods')
})
})

View File

@@ -5,15 +5,18 @@ import { getSuites, runSuite } from './lib/context.js'
import { createClient } from './lib/client.js' import { createClient } from './lib/client.js'
// --- Config --- // --- Config ---
// Use DATABASE_URL from env if available, otherwise construct from individual vars
const DB_HOST = process.env.DB_HOST ?? 'localhost' const DB_HOST = process.env.DB_HOST ?? 'localhost'
const DB_PORT = Number(process.env.DB_PORT ?? '5432') const DB_PORT = Number(process.env.DB_PORT ?? '5432')
const DB_USER = process.env.DB_USER ?? 'lunarfront' const DB_USER = process.env.DB_USER ?? 'lunarfront'
const DB_PASS = process.env.DB_PASS ?? 'lunarfront' const DB_PASS = process.env.DB_PASS ?? 'lunarfront'
const TEST_DB = 'lunarfront_api_test' const TEST_DB = 'lunarfront_api_test'
const DB_URL = process.env.DATABASE_URL ?? `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`
const USE_EXTERNAL_DB = !!process.env.DATABASE_URL
const TEST_PORT = 8001 const TEST_PORT = 8001
const BASE_URL = `http://localhost:${TEST_PORT}` const BASE_URL = `http://localhost:${TEST_PORT}`
const COMPANY_ID = 'a0000000-0000-0000-0000-000000000001' const COMPANY_ID = '10000000-1000-4000-8000-000000000001'
const LOCATION_ID = 'a0000000-0000-0000-0000-000000000002' const LOCATION_ID = '10000000-1000-4000-8000-000000000002'
// --- Parse CLI args --- // --- Parse CLI args ---
const args = process.argv.slice(2) const args = process.argv.slice(2)
@@ -27,13 +30,16 @@ for (let i = 0; i < args.length; i++) {
// --- DB setup --- // --- DB setup ---
async function setupDatabase() { async function setupDatabase() {
const adminSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/postgres`) if (!USE_EXTERNAL_DB) {
const [exists] = await adminSql`SELECT 1 FROM pg_database WHERE datname = ${TEST_DB}` // Local: create test DB if needed
if (!exists) { const adminSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/postgres`)
await adminSql.unsafe(`CREATE DATABASE ${TEST_DB}`) const [exists] = await adminSql`SELECT 1 FROM pg_database WHERE datname = ${TEST_DB}`
console.log(` Created database ${TEST_DB}`) if (!exists) {
await adminSql.unsafe(`CREATE DATABASE ${TEST_DB}`)
console.log(` Created database ${TEST_DB}`)
}
await adminSql.end()
} }
await adminSql.end()
// Run migrations // Run migrations
const { execSync } = await import('child_process') const { execSync } = await import('child_process')
@@ -41,13 +47,13 @@ async function setupDatabase() {
cwd: new URL('..', import.meta.url).pathname, cwd: new URL('..', import.meta.url).pathname,
env: { env: {
...process.env, ...process.env,
DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`, DATABASE_URL: DB_URL,
}, },
stdio: 'pipe', stdio: 'pipe',
}) })
// Truncate all tables // Truncate all tables
const testSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`) const testSql = postgres(DB_URL)
await testSql.unsafe(` await testSql.unsafe(`
DO $$ DECLARE r RECORD; DO $$ DECLARE r RECORD;
BEGIN BEGIN
@@ -61,7 +67,8 @@ async function setupDatabase() {
// Seed company + location (company table stays as store settings) // Seed company + location (company table stays as store settings)
await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Store', 'America/Chicago')` await testSql`INSERT INTO company (id, name, timezone) VALUES (${COMPANY_ID}, 'Test Store', 'America/Chicago')`
await testSql`INSERT INTO location (id, name) VALUES (${LOCATION_ID}, 'Test Location')` await testSql`INSERT INTO location (id, name, tax_rate, service_tax_rate) VALUES (${LOCATION_ID}, 'Test Location', '0.0825', '0.0500')`
await testSql`INSERT INTO location (id, name, tax_rate, service_tax_rate, cash_rounding) VALUES ('10000000-1000-4000-8000-000000000003', 'Rounding Location', '0.0825', '0.0500', true)`
// Seed lookup tables // Seed lookup tables
const { SYSTEM_UNIT_STATUSES, SYSTEM_ITEM_CONDITIONS } = await import('../src/db/schema/lookups.js') const { SYSTEM_UNIT_STATUSES, SYSTEM_ITEM_CONDITIONS } = await import('../src/db/schema/lookups.js')
@@ -115,7 +122,12 @@ async function setupDatabase() {
async function killPort(port: number) { async function killPort(port: number) {
try { try {
const { execSync } = await import('child_process') const { execSync } = await import('child_process')
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' }) // Try lsof first, fall back to fuser
try {
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { stdio: 'pipe' })
} catch {
execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { stdio: 'pipe' })
}
await new Promise((r) => setTimeout(r, 1000)) await new Promise((r) => setTimeout(r, 1000))
} catch {} } catch {}
} }
@@ -127,7 +139,7 @@ async function startBackend(): Promise<Subprocess> {
cwd: new URL('..', import.meta.url).pathname, cwd: new URL('..', import.meta.url).pathname,
env: { env: {
...process.env, ...process.env,
DATABASE_URL: `postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`, DATABASE_URL: DB_URL,
REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379', REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379',
JWT_SECRET: 'test-secret-for-api-tests', JWT_SECRET: 'test-secret-for-api-tests',
PORT: String(TEST_PORT), PORT: String(TEST_PORT),
@@ -181,7 +193,7 @@ async function registerTestUser(): Promise<string> {
// Assign admin role to the user via direct SQL // Assign admin role to the user via direct SQL
if (registerRes.status === 201 && registerData.user) { if (registerRes.status === 201 && registerData.user) {
const assignSql = postgres(`postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${TEST_DB}`) const assignSql = postgres(DB_URL)
const [adminRole] = await assignSql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1` const [adminRole] = await assignSql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
if (adminRole) { if (adminRole) {
await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING` await assignSql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${registerData.user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`

View File

@@ -0,0 +1,566 @@
import { suite } from '../lib/context.js'
const LOCATION_ID = '10000000-1000-4000-8000-000000000002'
const ROUNDING_LOCATION_ID = '10000000-1000-4000-8000-000000000003'
suite('POS', { tags: ['pos'] }, (t) => {
// ─── Discounts (CRUD) ──────────────────────────────────────────────────────
t.test('creates a discount', { tags: ['discounts', 'create'] }, async () => {
const res = await t.api.post('/v1/discounts', {
name: 'Employee 10%',
discountType: 'percent',
discountValue: 10,
appliesTo: 'line_item',
})
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Employee 10%')
t.assert.equal(res.data.discountType, 'percent')
t.assert.ok(res.data.id)
})
t.test('rejects discount without name', { tags: ['discounts', 'validation'] }, async () => {
const res = await t.api.post('/v1/discounts', { discountType: 'fixed', discountValue: 5 })
t.assert.status(res, 400)
})
t.test('lists discounts with pagination', { tags: ['discounts', 'list'] }, async () => {
await t.api.post('/v1/discounts', { name: 'Disc A', discountType: 'fixed', discountValue: 5 })
await t.api.post('/v1/discounts', { name: 'Disc B', discountType: 'percent', discountValue: 15 })
const res = await t.api.get('/v1/discounts', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination)
})
t.test('lists all discounts (lookup)', { tags: ['discounts', 'list'] }, async () => {
const res = await t.api.get('/v1/discounts/all')
t.assert.status(res, 200)
t.assert.ok(Array.isArray(res.data))
})
t.test('gets discount by id', { tags: ['discounts', 'read'] }, async () => {
const created = await t.api.post('/v1/discounts', { name: 'Get Me', discountType: 'fixed', discountValue: 3 })
const res = await t.api.get(`/v1/discounts/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'Get Me')
})
t.test('updates a discount', { tags: ['discounts', 'update'] }, async () => {
const created = await t.api.post('/v1/discounts', { name: 'Before', discountType: 'fixed', discountValue: 1 })
const res = await t.api.patch(`/v1/discounts/${created.data.id}`, { name: 'After', discountValue: 99 })
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'After')
})
t.test('soft-deletes a discount', { tags: ['discounts', 'delete'] }, async () => {
const created = await t.api.post('/v1/discounts', { name: 'Delete Me', discountType: 'fixed', discountValue: 1 })
const res = await t.api.del(`/v1/discounts/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Drawer Sessions ───────────────────────────────────────────────────────
t.test('opens a drawer session', { tags: ['drawer', 'create'] }, async () => {
const res = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
t.assert.status(res, 201)
t.assert.equal(res.data.status, 'open')
t.assert.ok(res.data.id)
// Close it so future tests can open a new one
await t.api.post(`/v1/drawer/${res.data.id}/close`, { closingBalance: 200 })
})
t.test('rejects opening second drawer at same location', { tags: ['drawer', 'validation'] }, async () => {
const first = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
t.assert.status(first, 201)
const second = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
t.assert.status(second, 409)
// Cleanup
await t.api.post(`/v1/drawer/${first.data.id}/close`, { closingBalance: 100 })
})
t.test('closes a drawer session with denominations', { tags: ['drawer', 'close'] }, async () => {
const opened = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 150 })
t.assert.status(opened, 201)
const res = await t.api.post(`/v1/drawer/${opened.data.id}/close`, {
closingBalance: 155,
denominations: { ones: 50, fives: 20, tens: 5, twenties: 2 },
notes: 'End of shift',
})
t.assert.status(res, 200)
t.assert.equal(res.data.status, 'closed')
t.assert.ok(res.data.closedAt)
})
t.test('gets current open drawer for location', { tags: ['drawer', 'read'] }, async () => {
const opened = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
t.assert.status(opened, 201)
const res = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
t.assert.status(res, 200)
t.assert.equal(res.data.id, opened.data.id)
// Cleanup
await t.api.post(`/v1/drawer/${opened.data.id}/close`, { closingBalance: 100 })
})
t.test('lists drawer sessions with pagination', { tags: ['drawer', 'list'] }, async () => {
const res = await t.api.get('/v1/drawer', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.pagination)
})
// ─── Transactions ──────────────────────────────────────────────────────────
t.test('creates a sale transaction', { tags: ['transactions', 'create'] }, async () => {
const res = await t.api.post('/v1/transactions', {
transactionType: 'sale',
locationId: LOCATION_ID,
})
t.assert.status(res, 201)
t.assert.equal(res.data.transactionType, 'sale')
t.assert.equal(res.data.status, 'pending')
t.assert.ok(res.data.transactionNumber)
t.assert.contains(res.data.transactionNumber, 'TXN-')
})
t.test('rejects transaction without type', { tags: ['transactions', 'validation'] }, async () => {
const res = await t.api.post('/v1/transactions', { locationId: LOCATION_ID })
t.assert.status(res, 400)
})
t.test('adds line items and calculates tax', { tags: ['transactions', 'line-items', 'tax'] }, async () => {
const txn = await t.api.post('/v1/transactions', {
transactionType: 'sale',
locationId: LOCATION_ID,
})
t.assert.status(txn, 201)
// Add a line item (no product — ad hoc, taxed as goods at 8.25%)
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Violin Strings',
qty: 2,
unitPrice: 12.99,
})
t.assert.status(item, 201)
t.assert.equal(item.data.description, 'Violin Strings')
t.assert.equal(item.data.qty, 2)
// Verify tax was applied (8.25% on $25.98 = ~$2.14)
const taxAmount = parseFloat(item.data.taxAmount)
t.assert.greaterThan(taxAmount, 0)
// Verify transaction totals were recalculated
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
t.assert.status(updated, 200)
const total = parseFloat(updated.data.total)
t.assert.greaterThan(total, 0)
})
t.test('removes a line item and recalculates', { tags: ['transactions', 'line-items'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
const item1 = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Item A',
qty: 1,
unitPrice: 10,
})
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Item B',
qty: 1,
unitPrice: 20,
})
// Remove item A
const del = await t.api.del(`/v1/transactions/${txn.data.id}/line-items/${item1.data.id}`)
t.assert.status(del, 200)
// Transaction should only have item B's total
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
const subtotal = parseFloat(updated.data.subtotal)
t.assert.equal(subtotal, 20)
})
t.test('tax exempt transaction has zero tax', { tags: ['transactions', 'tax'] }, async () => {
const txn = await t.api.post('/v1/transactions', {
transactionType: 'sale',
locationId: LOCATION_ID,
taxExempt: true,
taxExemptReason: 'Non-profit org',
})
t.assert.status(txn, 201)
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Exempt Item',
qty: 1,
unitPrice: 100,
})
t.assert.status(item, 201)
t.assert.equal(parseFloat(item.data.taxAmount), 0)
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
t.assert.equal(parseFloat(updated.data.taxTotal), 0)
t.assert.equal(parseFloat(updated.data.total), 100)
})
t.test('transaction without location has zero tax', { tags: ['transactions', 'tax'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale' })
t.assert.status(txn, 201)
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'No-location Item',
qty: 1,
unitPrice: 50,
})
t.assert.status(item, 201)
t.assert.equal(parseFloat(item.data.taxAmount), 0)
})
// ─── Discounts on Transactions ─────────────────────────────────────────────
t.test('applies a line-item discount', { tags: ['transactions', 'discounts'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Discountable Item',
qty: 1,
unitPrice: 100,
})
const res = await t.api.post(`/v1/transactions/${txn.data.id}/discounts`, {
amount: 15,
reason: 'Loyalty discount',
lineItemId: item.data.id,
})
t.assert.status(res, 200)
// Verify discount reduced the total
const updated = await t.api.get(`/v1/transactions/${txn.data.id}`)
const discountTotal = parseFloat(updated.data.discountTotal)
t.assert.equal(discountTotal, 15)
// Total should be (100 - 15) + tax on 85
const total = parseFloat(updated.data.total)
t.assert.greaterThan(total, 80)
})
t.test('rejects discount exceeding approval threshold', { tags: ['transactions', 'discounts', 'validation'] }, async () => {
// Create a discount with approval threshold
const disc = await t.api.post('/v1/discounts', {
name: 'Threshold Disc',
discountType: 'fixed',
discountValue: 50,
requiresApprovalAbove: 20,
})
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
const item = await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Expensive Item',
qty: 1,
unitPrice: 200,
})
// Try to apply $25 discount (above $20 threshold)
const res = await t.api.post(`/v1/transactions/${txn.data.id}/discounts`, {
discountId: disc.data.id,
amount: 25,
reason: 'Trying too much',
lineItemId: item.data.id,
})
t.assert.status(res, 400)
})
// ─── Complete Transaction ──────────────────────────────────────────────────
t.test('completes a cash transaction with change', { tags: ['transactions', 'complete', 'cash'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Cash Sale Item',
qty: 1,
unitPrice: 10,
})
// Get updated total
const pending = await t.api.get(`/v1/transactions/${txn.data.id}`)
const total = parseFloat(pending.data.total)
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'cash',
amountTendered: 20,
})
t.assert.status(res, 200)
t.assert.equal(res.data.status, 'completed')
t.assert.equal(res.data.paymentMethod, 'cash')
const changeGiven = parseFloat(res.data.changeGiven)
// change = 20 - total
const expectedChange = 20 - total
t.assert.equal(changeGiven, expectedChange)
})
t.test('rejects cash payment with insufficient amount', { tags: ['transactions', 'complete', 'validation'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Underpay Item',
qty: 1,
unitPrice: 100,
})
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'cash',
amountTendered: 5,
})
t.assert.status(res, 400)
})
t.test('completes a card transaction', { tags: ['transactions', 'complete'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Card Sale Item',
qty: 1,
unitPrice: 49.99,
})
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'card_present',
})
t.assert.status(res, 200)
t.assert.equal(res.data.status, 'completed')
t.assert.equal(res.data.paymentMethod, 'card_present')
})
t.test('rejects completing a non-pending transaction', { tags: ['transactions', 'complete', 'validation'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Double Complete',
qty: 1,
unitPrice: 10,
})
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'card_present',
})
// Try completing again
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'cash',
amountTendered: 100,
})
t.assert.status(res, 409)
})
// ─── Void Transaction ──────────────────────────────────────────────────────
t.test('voids a pending transaction', { tags: ['transactions', 'void'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Void Me',
qty: 1,
unitPrice: 25,
})
const res = await t.api.post(`/v1/transactions/${txn.data.id}/void`)
t.assert.status(res, 200)
t.assert.equal(res.data.status, 'voided')
})
t.test('rejects voiding a completed transaction', { tags: ['transactions', 'void', 'validation'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'No Void',
qty: 1,
unitPrice: 10,
})
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
const res = await t.api.post(`/v1/transactions/${txn.data.id}/void`)
t.assert.status(res, 409)
})
// ─── Receipt ───────────────────────────────────────────────────────────────
t.test('gets transaction receipt', { tags: ['transactions', 'receipt'] }, async () => {
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Receipt Item',
qty: 1,
unitPrice: 42,
})
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
const res = await t.api.get(`/v1/transactions/${txn.data.id}/receipt`)
t.assert.status(res, 200)
t.assert.ok(res.data.transaction)
t.assert.ok(res.data.company)
t.assert.ok(res.data.location)
t.assert.equal(res.data.transaction.transactionNumber.startsWith('TXN-'), true)
})
// ─── List Transactions ─────────────────────────────────────────────────────
t.test('lists transactions with pagination', { tags: ['transactions', 'list'] }, async () => {
const res = await t.api.get('/v1/transactions', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 1)
t.assert.ok(res.data.pagination)
})
t.test('filters transactions by status', { tags: ['transactions', 'list', 'filter'] }, async () => {
const res = await t.api.get('/v1/transactions', { status: 'completed' })
t.assert.status(res, 200)
for (const txn of res.data.data) {
t.assert.equal(txn.status, 'completed')
}
})
// ─── Tax Lookup (stub) ────────────────────────────────────────────────────
t.test('tax lookup returns 501 (not configured)', { tags: ['tax'] }, async () => {
const res = await t.api.get('/v1/tax/lookup/90210')
t.assert.status(res, 501)
})
t.test('rejects invalid zip format', { tags: ['tax', 'validation'] }, async () => {
const res = await t.api.get('/v1/tax/lookup/abc')
t.assert.status(res, 400)
})
// ─── Cash Rounding ─────────────────────────────────────────────────────────
t.test('cash rounding adjusts total to nearest nickel', { tags: ['transactions', 'rounding'] }, async () => {
// Create transaction at the rounding-enabled location
const txn = await t.api.post('/v1/transactions', {
transactionType: 'sale',
locationId: ROUNDING_LOCATION_ID,
})
t.assert.status(txn, 201)
// Add item that will produce a total not divisible by $0.05
// $10.01 + 8.25% tax = $10.01 + $0.83 = $10.84 → rounds to $10.85
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Rounding Test Item',
qty: 1,
unitPrice: 10.01,
})
const pending = await t.api.get(`/v1/transactions/${txn.data.id}`)
const exactTotal = parseFloat(pending.data.total)
// Complete with cash — should round
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'cash',
amountTendered: 20,
})
t.assert.status(res, 200)
const roundingAdj = parseFloat(res.data.roundingAdjustment)
const changeGiven = parseFloat(res.data.changeGiven)
const roundedTotal = exactTotal + roundingAdj
// Rounded total should be divisible by 0.05
t.assert.equal(Math.round(roundedTotal * 100) % 5, 0)
// Change should be based on rounded total
t.assert.equal(changeGiven, Math.round((20 - roundedTotal) * 100) / 100)
// Adjustment should be small (-0.02 to +0.02)
t.assert.ok(Math.abs(roundingAdj) <= 0.02)
})
t.test('card payment skips rounding even at rounding location', { tags: ['transactions', 'rounding'] }, async () => {
const txn = await t.api.post('/v1/transactions', {
transactionType: 'sale',
locationId: ROUNDING_LOCATION_ID,
})
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Card Rounding Test',
qty: 1,
unitPrice: 10.01,
})
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'card_present',
})
t.assert.status(res, 200)
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
})
t.test('no rounding at non-rounding location', { tags: ['transactions', 'rounding'] }, async () => {
const txn = await t.api.post('/v1/transactions', {
transactionType: 'sale',
locationId: LOCATION_ID,
})
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'No Rounding Item',
qty: 1,
unitPrice: 10.01,
})
const res = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'cash',
amountTendered: 20,
})
t.assert.status(res, 200)
t.assert.equal(parseFloat(res.data.roundingAdjustment), 0)
})
// ─── Full POS Flow ────────────────────────────────────────────────────────
t.test('full sale flow: open drawer, sell, close drawer', { tags: ['e2e'] }, async () => {
// 1. Open drawer
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
t.assert.status(drawer, 201)
// 2. Create transaction
const txn = await t.api.post('/v1/transactions', {
transactionType: 'sale',
locationId: LOCATION_ID,
})
t.assert.status(txn, 201)
// 3. Add line items
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Guitar Pick (12pk)',
qty: 3,
unitPrice: 5.99,
})
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, {
description: 'Capo',
qty: 1,
unitPrice: 19.99,
})
// 4. Verify totals
const pending = await t.api.get(`/v1/transactions/${txn.data.id}`)
t.assert.status(pending, 200)
const subtotal = parseFloat(pending.data.subtotal)
const taxTotal = parseFloat(pending.data.taxTotal)
const total = parseFloat(pending.data.total)
// subtotal = 3*5.99 + 19.99 = 37.96
t.assert.equal(subtotal, 37.96)
t.assert.greaterThan(taxTotal, 0)
t.assert.equal(total, subtotal + taxTotal)
t.assert.equal(pending.data.lineItems.length, 2)
// 5. Complete with cash
const completed = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'cash',
amountTendered: 50,
})
t.assert.status(completed, 200)
t.assert.equal(completed.data.status, 'completed')
const change = parseFloat(completed.data.changeGiven)
t.assert.equal(change, Math.round((50 - total) * 100) / 100)
// 6. Get receipt
const receipt = await t.api.get(`/v1/transactions/${txn.data.id}/receipt`)
t.assert.status(receipt, 200)
t.assert.equal(receipt.data.transaction.status, 'completed')
// 7. Close drawer
const closed = await t.api.post(`/v1/drawer/${drawer.data.id}/close`, {
closingBalance: 100 + total - change,
notes: 'End of day',
})
t.assert.status(closed, 200)
t.assert.equal(closed.data.status, 'closed')
})
})

View File

@@ -1,6 +1,6 @@
{ {
"name": "@lunarfront/backend", "name": "@lunarfront/backend",
"version": "0.0.25", "version": "0.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -2,3 +2,4 @@ export * from './schema/stores.js'
export * from './schema/users.js' export * from './schema/users.js'
export * from './schema/accounts.js' export * from './schema/accounts.js'
export * from './schema/inventory.js' export * from './schema/inventory.js'
export * from './schema/pos.js'

View File

@@ -0,0 +1,111 @@
-- POS core: enums, tables, and new columns on existing tables
-- New enums
CREATE TYPE "public"."transaction_type" AS ENUM('sale', 'repair_payment', 'rental_deposit', 'account_payment', 'refund');
CREATE TYPE "public"."transaction_status" AS ENUM('pending', 'completed', 'voided', 'refunded');
CREATE TYPE "public"."payment_method" AS ENUM('cash', 'card_present', 'card_keyed', 'check', 'account_charge');
CREATE TYPE "public"."discount_type" AS ENUM('percent', 'fixed');
CREATE TYPE "public"."discount_applies_to" AS ENUM('order', 'line_item', 'category');
CREATE TYPE "public"."drawer_status" AS ENUM('open', 'closed');
CREATE TYPE "public"."tax_category" AS ENUM('goods', 'service', 'exempt');
-- New columns on existing tables
ALTER TABLE "product" ADD COLUMN "tax_category" "tax_category" NOT NULL DEFAULT 'goods';
ALTER TABLE "location" ADD COLUMN "tax_rate" numeric(5, 4) NOT NULL DEFAULT '0';
ALTER TABLE "location" ADD COLUMN "service_tax_rate" numeric(5, 4) NOT NULL DEFAULT '0';
-- Discount table
CREATE TABLE "discount" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"location_id" uuid REFERENCES "location"("id"),
"name" varchar(255) NOT NULL,
"discount_type" "discount_type" NOT NULL,
"discount_value" numeric(10, 2) NOT NULL,
"applies_to" "discount_applies_to" NOT NULL DEFAULT 'line_item',
"requires_approval_above" numeric(10, 2),
"is_active" boolean NOT NULL DEFAULT true,
"valid_from" timestamp with time zone,
"valid_until" timestamp with time zone,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
-- Drawer session table
CREATE TABLE "drawer_session" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"location_id" uuid REFERENCES "location"("id"),
"opened_by" uuid NOT NULL REFERENCES "user"("id"),
"closed_by" uuid REFERENCES "user"("id"),
"opening_balance" numeric(10, 2) NOT NULL,
"closing_balance" numeric(10, 2),
"expected_balance" numeric(10, 2),
"over_short" numeric(10, 2),
"denominations" jsonb,
"status" "drawer_status" NOT NULL DEFAULT 'open',
"notes" text,
"opened_at" timestamp with time zone NOT NULL DEFAULT now(),
"closed_at" timestamp with time zone
);
-- Transaction table
CREATE TABLE "transaction" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"location_id" uuid REFERENCES "location"("id"),
"transaction_number" varchar(50) NOT NULL UNIQUE,
"account_id" uuid REFERENCES "account"("id"),
"repair_ticket_id" uuid REFERENCES "repair_ticket"("id"),
"repair_batch_id" uuid REFERENCES "repair_batch"("id"),
"transaction_type" "transaction_type" NOT NULL,
"status" "transaction_status" NOT NULL DEFAULT 'pending',
"subtotal" numeric(10, 2) NOT NULL DEFAULT '0',
"discount_total" numeric(10, 2) NOT NULL DEFAULT '0',
"tax_total" numeric(10, 2) NOT NULL DEFAULT '0',
"total" numeric(10, 2) NOT NULL DEFAULT '0',
"payment_method" "payment_method",
"amount_tendered" numeric(10, 2),
"change_given" numeric(10, 2),
"check_number" varchar(50),
"stripe_payment_intent_id" varchar(255),
"tax_exempt" boolean NOT NULL DEFAULT false,
"tax_exempt_reason" text,
"processed_by" uuid NOT NULL REFERENCES "user"("id"),
"drawer_session_id" uuid REFERENCES "drawer_session"("id"),
"notes" text,
"completed_at" timestamp with time zone,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
-- Transaction line item table
CREATE TABLE "transaction_line_item" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"transaction_id" uuid NOT NULL REFERENCES "transaction"("id"),
"product_id" uuid REFERENCES "product"("id"),
"inventory_unit_id" uuid REFERENCES "inventory_unit"("id"),
"description" varchar(255) NOT NULL,
"qty" integer NOT NULL DEFAULT 1,
"unit_price" numeric(10, 2) NOT NULL,
"discount_amount" numeric(10, 2) NOT NULL DEFAULT '0',
"discount_reason" text,
"tax_rate" numeric(5, 4) NOT NULL DEFAULT '0',
"tax_amount" numeric(10, 2) NOT NULL DEFAULT '0',
"line_total" numeric(10, 2) NOT NULL DEFAULT '0',
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
-- Discount audit table (append-only)
CREATE TABLE "discount_audit" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"transaction_id" uuid NOT NULL REFERENCES "transaction"("id"),
"transaction_line_item_id" uuid REFERENCES "transaction_line_item"("id"),
"discount_id" uuid REFERENCES "discount"("id"),
"applied_by" uuid NOT NULL REFERENCES "user"("id"),
"approved_by" uuid REFERENCES "user"("id"),
"original_amount" numeric(10, 2) NOT NULL,
"discounted_amount" numeric(10, 2) NOT NULL,
"reason" text NOT NULL,
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
-- SKU unique partial index (from prior untracked migration)
CREATE UNIQUE INDEX IF NOT EXISTS products_sku_unique ON product (sku) WHERE sku IS NOT NULL;

View File

@@ -1,2 +0,0 @@
-- Add unique index on products.sku (null values are excluded from uniqueness)
CREATE UNIQUE INDEX IF NOT EXISTS products_sku_unique ON product (sku) WHERE sku IS NOT NULL;

View File

@@ -0,0 +1,3 @@
-- Cash rounding: location setting + transaction adjustment tracking
ALTER TABLE "location" ADD COLUMN "cash_rounding" boolean NOT NULL DEFAULT false;
ALTER TABLE "transaction" ADD COLUMN "rounding_adjustment" numeric(10, 2) NOT NULL DEFAULT '0';

View File

@@ -267,6 +267,20 @@
"when": 1774970000000, "when": 1774970000000,
"tag": "0037_rate_cycles", "tag": "0037_rate_cycles",
"breakpoints": true "breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1775321562910,
"tag": "0038_pos-core",
"breakpoints": true
},
{
"idx": 39,
"version": "7",
"when": 1775408000000,
"tag": "0039_cash-rounding",
"breakpoints": true
} }
] ]
} }

View File

@@ -8,6 +8,7 @@ import {
integer, integer,
numeric, numeric,
date, date,
pgEnum,
} from 'drizzle-orm/pg-core' } from 'drizzle-orm/pg-core'
import { locations } from './stores.js' import { locations } from './stores.js'
@@ -41,6 +42,8 @@ export const suppliers = pgTable('supplier', {
// See lookups.ts for inventory_unit_status and item_condition tables. // See lookups.ts for inventory_unit_status and item_condition tables.
// Columns below use varchar referencing the lookup slug. // Columns below use varchar referencing the lookup slug.
export const taxCategoryEnum = pgEnum('tax_category', ['goods', 'service', 'exempt'])
export const products = pgTable('product', { export const products = pgTable('product', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id').references(() => locations.id), locationId: uuid('location_id').references(() => locations.id),
@@ -54,6 +57,7 @@ export const products = pgTable('product', {
isSerialized: boolean('is_serialized').notNull().default(false), isSerialized: boolean('is_serialized').notNull().default(false),
isRental: boolean('is_rental').notNull().default(false), isRental: boolean('is_rental').notNull().default(false),
isDualUseRepair: boolean('is_dual_use_repair').notNull().default(false), isDualUseRepair: boolean('is_dual_use_repair').notNull().default(false),
taxCategory: taxCategoryEnum('tax_category').notNull().default('goods'),
price: numeric('price', { precision: 10, scale: 2 }), price: numeric('price', { precision: 10, scale: 2 }),
minPrice: numeric('min_price', { precision: 10, scale: 2 }), minPrice: numeric('min_price', { precision: 10, scale: 2 }),
rentalRateMonthly: numeric('rental_rate_monthly', { precision: 10, scale: 2 }), rentalRateMonthly: numeric('rental_rate_monthly', { precision: 10, scale: 2 }),

View File

@@ -0,0 +1,166 @@
import {
pgTable,
uuid,
varchar,
text,
timestamp,
boolean,
integer,
numeric,
pgEnum,
jsonb,
} from 'drizzle-orm/pg-core'
import { locations } from './stores.js'
import { accounts } from './accounts.js'
import { products, inventoryUnits } from './inventory.js'
import { users } from './users.js'
import { repairTickets, repairBatches } from './repairs.js'
// --- Enums ---
export const transactionTypeEnum = pgEnum('transaction_type', [
'sale',
'repair_payment',
'rental_deposit',
'account_payment',
'refund',
])
export const transactionStatusEnum = pgEnum('transaction_status', [
'pending',
'completed',
'voided',
'refunded',
])
export const paymentMethodEnum = pgEnum('payment_method', [
'cash',
'card_present',
'card_keyed',
'check',
'account_charge',
])
export const discountTypeEnum = pgEnum('discount_type', ['percent', 'fixed'])
export const discountAppliesToEnum = pgEnum('discount_applies_to', [
'order',
'line_item',
'category',
])
export const drawerStatusEnum = pgEnum('drawer_status', ['open', 'closed'])
// --- Tables ---
export const discounts = pgTable('discount', {
id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id').references(() => locations.id),
name: varchar('name', { length: 255 }).notNull(),
discountType: discountTypeEnum('discount_type').notNull(),
discountValue: numeric('discount_value', { precision: 10, scale: 2 }).notNull(),
appliesTo: discountAppliesToEnum('applies_to').notNull().default('line_item'),
requiresApprovalAbove: numeric('requires_approval_above', { precision: 10, scale: 2 }),
isActive: boolean('is_active').notNull().default(true),
validFrom: timestamp('valid_from', { withTimezone: true }),
validUntil: timestamp('valid_until', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const drawerSessions = pgTable('drawer_session', {
id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id').references(() => locations.id),
openedBy: uuid('opened_by')
.notNull()
.references(() => users.id),
closedBy: uuid('closed_by').references(() => users.id),
openingBalance: numeric('opening_balance', { precision: 10, scale: 2 }).notNull(),
closingBalance: numeric('closing_balance', { precision: 10, scale: 2 }),
expectedBalance: numeric('expected_balance', { precision: 10, scale: 2 }),
overShort: numeric('over_short', { precision: 10, scale: 2 }),
denominations: jsonb('denominations').$type<Record<string, number>>(),
status: drawerStatusEnum('status').notNull().default('open'),
notes: text('notes'),
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
closedAt: timestamp('closed_at', { withTimezone: true }),
})
export const transactions = pgTable('transaction', {
id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id').references(() => locations.id),
transactionNumber: varchar('transaction_number', { length: 50 }).notNull().unique(),
accountId: uuid('account_id').references(() => accounts.id),
repairTicketId: uuid('repair_ticket_id').references(() => repairTickets.id),
repairBatchId: uuid('repair_batch_id').references(() => repairBatches.id),
transactionType: transactionTypeEnum('transaction_type').notNull(),
status: transactionStatusEnum('status').notNull().default('pending'),
subtotal: numeric('subtotal', { precision: 10, scale: 2 }).notNull().default('0'),
discountTotal: numeric('discount_total', { precision: 10, scale: 2 }).notNull().default('0'),
taxTotal: numeric('tax_total', { precision: 10, scale: 2 }).notNull().default('0'),
total: numeric('total', { precision: 10, scale: 2 }).notNull().default('0'),
paymentMethod: paymentMethodEnum('payment_method'),
amountTendered: numeric('amount_tendered', { precision: 10, scale: 2 }),
changeGiven: numeric('change_given', { precision: 10, scale: 2 }),
checkNumber: varchar('check_number', { length: 50 }),
stripePaymentIntentId: varchar('stripe_payment_intent_id', { length: 255 }),
roundingAdjustment: numeric('rounding_adjustment', { precision: 10, scale: 2 }).notNull().default('0'),
taxExempt: boolean('tax_exempt').notNull().default(false),
taxExemptReason: text('tax_exempt_reason'),
processedBy: uuid('processed_by')
.notNull()
.references(() => users.id),
drawerSessionId: uuid('drawer_session_id').references(() => drawerSessions.id),
notes: text('notes'),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const transactionLineItems = pgTable('transaction_line_item', {
id: uuid('id').primaryKey().defaultRandom(),
transactionId: uuid('transaction_id')
.notNull()
.references(() => transactions.id),
productId: uuid('product_id').references(() => products.id),
inventoryUnitId: uuid('inventory_unit_id').references(() => inventoryUnits.id),
description: varchar('description', { length: 255 }).notNull(),
qty: integer('qty').notNull().default(1),
unitPrice: numeric('unit_price', { precision: 10, scale: 2 }).notNull(),
discountAmount: numeric('discount_amount', { precision: 10, scale: 2 }).notNull().default('0'),
discountReason: text('discount_reason'),
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }).notNull().default('0'),
taxAmount: numeric('tax_amount', { precision: 10, scale: 2 }).notNull().default('0'),
lineTotal: numeric('line_total', { precision: 10, scale: 2 }).notNull().default('0'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const discountAudits = pgTable('discount_audit', {
id: uuid('id').primaryKey().defaultRandom(),
transactionId: uuid('transaction_id')
.notNull()
.references(() => transactions.id),
transactionLineItemId: uuid('transaction_line_item_id').references(() => transactionLineItems.id),
discountId: uuid('discount_id').references(() => discounts.id),
appliedBy: uuid('applied_by')
.notNull()
.references(() => users.id),
approvedBy: uuid('approved_by').references(() => users.id),
originalAmount: numeric('original_amount', { precision: 10, scale: 2 }).notNull(),
discountedAmount: numeric('discounted_amount', { precision: 10, scale: 2 }).notNull(),
reason: text('reason').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
// --- Type exports ---
export type Discount = typeof discounts.$inferSelect
export type DiscountInsert = typeof discounts.$inferInsert
export type Transaction = typeof transactions.$inferSelect
export type TransactionInsert = typeof transactions.$inferInsert
export type TransactionLineItem = typeof transactionLineItems.$inferSelect
export type TransactionLineItemInsert = typeof transactionLineItems.$inferInsert
export type DiscountAudit = typeof discountAudits.$inferSelect
export type DiscountAuditInsert = typeof discountAudits.$inferInsert
export type DrawerSession = typeof drawerSessions.$inferSelect
export type DrawerSessionInsert = typeof drawerSessions.$inferInsert

View File

@@ -1,4 +1,4 @@
import { pgTable, uuid, varchar, text, jsonb, timestamp, boolean } from 'drizzle-orm/pg-core' import { pgTable, uuid, varchar, text, jsonb, timestamp, boolean, numeric } from 'drizzle-orm/pg-core'
export const companies = pgTable('company', { export const companies = pgTable('company', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@@ -30,6 +30,9 @@ export const locations = pgTable('location', {
phone: varchar('phone', { length: 50 }), phone: varchar('phone', { length: 50 }),
email: varchar('email', { length: 255 }), email: varchar('email', { length: 255 }),
timezone: varchar('timezone', { length: 100 }), timezone: varchar('timezone', { length: 100 }),
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }),
serviceTaxRate: numeric('service_tax_rate', { precision: 5, scale: 4 }),
cashRounding: boolean('cash_rounding').notNull().default(false),
isActive: boolean('is_active').notNull().default(true), isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -21,6 +21,10 @@ import { fileRoutes } from './routes/v1/files.js'
import { rbacRoutes } from './routes/v1/rbac.js' import { rbacRoutes } from './routes/v1/rbac.js'
import { repairRoutes } from './routes/v1/repairs.js' import { repairRoutes } from './routes/v1/repairs.js'
import { lessonRoutes } from './routes/v1/lessons.js' import { lessonRoutes } from './routes/v1/lessons.js'
import { transactionRoutes } from './routes/v1/transactions.js'
import { drawerRoutes } from './routes/v1/drawer.js'
import { discountRoutes } from './routes/v1/discounts.js'
import { taxRoutes } from './routes/v1/tax.js'
import { storageRoutes } from './routes/v1/storage.js' import { storageRoutes } from './routes/v1/storage.js'
import { storeRoutes } from './routes/v1/store.js' import { storeRoutes } from './routes/v1/store.js'
import { vaultRoutes } from './routes/v1/vault.js' import { vaultRoutes } from './routes/v1/vault.js'
@@ -111,6 +115,10 @@ export async function buildApp() {
await app.register(withModule('files', storageRoutes), { prefix: '/v1' }) await app.register(withModule('files', storageRoutes), { prefix: '/v1' })
await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' }) await app.register(withModule('repairs', repairRoutes), { prefix: '/v1' })
await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' }) await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' })
await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' })
await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' })
await app.register(withModule('pos', discountRoutes), { prefix: '/v1' })
await app.register(withModule('pos', taxRoutes), { prefix: '/v1' })
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' }) await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })
// Register WebDAV custom HTTP methods before routes // Register WebDAV custom HTTP methods before routes
app.addHttpMethod('PROPFIND', { hasBody: true }) app.addHttpMethod('PROPFIND', { hasBody: true })

View File

@@ -5,8 +5,9 @@ import * as storeSchema from '../db/schema/stores.js'
import * as userSchema from '../db/schema/users.js' import * as userSchema from '../db/schema/users.js'
import * as accountSchema from '../db/schema/accounts.js' import * as accountSchema from '../db/schema/accounts.js'
import * as inventorySchema from '../db/schema/inventory.js' import * as inventorySchema from '../db/schema/inventory.js'
import * as posSchema from '../db/schema/pos.js'
const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema } const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema, ...posSchema }
declare module 'fastify' { declare module 'fastify' {
interface FastifyInstance { interface FastifyInstance {

View File

@@ -9,7 +9,8 @@ declare module 'fastify' {
export const redisPlugin = fp(async (app) => { export const redisPlugin = fp(async (app) => {
const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379' const redisUrl = process.env.REDIS_URL ?? 'redis://localhost:6379'
const redis = new Redis(redisUrl) const keyPrefix = process.env.REDIS_KEY_PREFIX ? `${process.env.REDIS_KEY_PREFIX}:` : ''
const redis = new Redis(redisUrl, { keyPrefix })
app.decorate('redis', redis) app.decorate('redis', redis)

View File

@@ -0,0 +1,51 @@
import type { FastifyPluginAsync } from 'fastify'
import { PaginationSchema, DiscountCreateSchema, DiscountUpdateSchema } from '@lunarfront/shared/schemas'
import { DiscountService } from '../../services/discount.service.js'
export const discountRoutes: FastifyPluginAsync = async (app) => {
app.post('/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
const parsed = DiscountCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const discount = await DiscountService.create(app.db, parsed.data)
return reply.status(201).send(discount)
})
app.get('/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query)
const result = await DiscountService.list(app.db, params)
return reply.send(result)
})
app.get('/discounts/all', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const discounts = await DiscountService.listAll(app.db)
return reply.send(discounts)
})
app.get('/discounts/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const discount = await DiscountService.getById(app.db, id)
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
return reply.send(discount)
})
app.patch('/discounts/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = DiscountUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const discount = await DiscountService.update(app.db, id, parsed.data)
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
return reply.send(discount)
})
app.delete('/discounts/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const discount = await DiscountService.softDelete(app.db, id)
if (!discount) return reply.status(404).send({ error: { message: 'Discount not found', statusCode: 404 } })
return reply.send(discount)
})
}

View File

@@ -0,0 +1,49 @@
import type { FastifyPluginAsync } from 'fastify'
import { PaginationSchema, DrawerOpenSchema, DrawerCloseSchema } from '@lunarfront/shared/schemas'
import { DrawerService } from '../../services/drawer.service.js'
export const drawerRoutes: FastifyPluginAsync = async (app) => {
app.post('/drawer/open', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const parsed = DrawerOpenSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const session = await DrawerService.open(app.db, parsed.data, request.user.id)
return reply.status(201).send(session)
})
app.post('/drawer/:id/close', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = DrawerCloseSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const session = await DrawerService.close(app.db, id, parsed.data, request.user.id)
return reply.send(session)
})
app.get('/drawer/current', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const locationId = query.locationId
if (!locationId) {
return reply.status(400).send({ error: { message: 'locationId query param is required', statusCode: 400 } })
}
const session = await DrawerService.getOpen(app.db, locationId)
if (!session) return reply.status(404).send({ error: { message: 'No open drawer session found', statusCode: 404 } })
return reply.send(session)
})
app.get('/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const session = await DrawerService.getById(app.db, id)
if (!session) return reply.status(404).send({ error: { message: 'Drawer session not found', statusCode: 404 } })
return reply.send(session)
})
app.get('/drawer', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query)
const result = await DrawerService.list(app.db, params)
return reply.send(result)
})
}

View File

@@ -12,6 +12,15 @@ import {
import { ProductService, InventoryUnitService, ProductSupplierService, StockReceiptService } from '../../services/product.service.js' import { ProductService, InventoryUnitService, ProductSupplierService, StockReceiptService } from '../../services/product.service.js'
export const productRoutes: FastifyPluginAsync = async (app) => { export const productRoutes: FastifyPluginAsync = async (app) => {
// --- UPC Barcode Lookup ---
app.get('/products/lookup/upc/:upc', { preHandler: [app.authenticate, app.requirePermission('inventory.view')] }, async (request, reply) => {
const { upc } = request.params as { upc: string }
const product = await ProductService.getByUpc(app.db, upc)
if (!product) return reply.status(404).send({ error: { message: 'No product found for this UPC', statusCode: 404 } })
return reply.send(product)
})
// --- Products --- // --- Products ---
app.post('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => { app.post('/products', { preHandler: [app.authenticate, app.requirePermission('inventory.edit')] }, async (request, reply) => {

View File

@@ -0,0 +1,15 @@
import type { FastifyPluginAsync } from 'fastify'
import { TaxService } from '../../services/tax.service.js'
export const taxRoutes: FastifyPluginAsync = async (app) => {
app.get('/tax/lookup/:zip', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { zip } = request.params as { zip: string }
if (!/^\d{5}(-\d{4})?$/.test(zip)) {
return reply.status(400).send({ error: { message: 'Invalid zip code format', statusCode: 400 } })
}
const result = await TaxService.lookupByZip(zip)
return reply.send(result)
})
}

View File

@@ -0,0 +1,92 @@
import type { FastifyPluginAsync } from 'fastify'
import {
PaginationSchema,
TransactionCreateSchema,
TransactionLineItemCreateSchema,
ApplyDiscountSchema,
CompleteTransactionSchema,
} from '@lunarfront/shared/schemas'
import { TransactionService } from '../../services/transaction.service.js'
export const transactionRoutes: FastifyPluginAsync = async (app) => {
app.post('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const parsed = TransactionCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const txn = await TransactionService.create(app.db, parsed.data, request.user.id)
return reply.status(201).send(txn)
})
app.get('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query)
const filters = {
status: query.status,
transactionType: query.transactionType,
locationId: query.locationId,
}
const result = await TransactionService.list(app.db, params, filters)
return reply.send(result)
})
app.get('/transactions/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const txn = await TransactionService.getById(app.db, id)
if (!txn) return reply.status(404).send({ error: { message: 'Transaction not found', statusCode: 404 } })
return reply.send(txn)
})
app.get('/transactions/:id/receipt', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const receipt = await TransactionService.getReceipt(app.db, id)
return reply.send(receipt)
})
app.post('/transactions/:id/line-items', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = TransactionLineItemCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const lineItem = await TransactionService.addLineItem(app.db, id, parsed.data)
return reply.status(201).send(lineItem)
})
app.delete('/transactions/:id/line-items/:lineItemId', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id, lineItemId } = request.params as { id: string; lineItemId: string }
const deleted = await TransactionService.removeLineItem(app.db, id, lineItemId)
return reply.send(deleted)
})
app.post('/transactions/:id/discounts', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = ApplyDiscountSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
await TransactionService.applyDiscount(app.db, id, parsed.data, request.user.id)
const txn = await TransactionService.getById(app.db, id)
return reply.send(txn)
})
app.post('/transactions/:id/complete', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = CompleteTransactionSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
await TransactionService.complete(app.db, id, parsed.data)
const txn = await TransactionService.getById(app.db, id)
return reply.send(txn)
})
app.post('/transactions/:id/void', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
await TransactionService.void(app.db, id, request.user.id)
const txn = await TransactionService.getById(app.db, id)
return reply.send(txn)
})
}

View File

@@ -0,0 +1,89 @@
import { eq, and, count, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { discounts } from '../db/schema/pos.js'
import type { DiscountCreateInput, DiscountUpdateInput, PaginationInput } from '@lunarfront/shared/schemas'
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
export const DiscountService = {
async create(db: PostgresJsDatabase<any>, input: DiscountCreateInput) {
const [discount] = await db
.insert(discounts)
.values({
...input,
discountValue: input.discountValue.toString(),
requiresApprovalAbove: input.requiresApprovalAbove?.toString(),
validFrom: input.validFrom ? new Date(input.validFrom) : undefined,
validUntil: input.validUntil ? new Date(input.validUntil) : undefined,
})
.returning()
return discount
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [discount] = await db
.select()
.from(discounts)
.where(eq(discounts.id, id))
.limit(1)
return discount ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const conditions = [eq(discounts.isActive, true)]
if (params.q) {
conditions.push(buildSearchCondition(params.q, [discounts.name])!)
}
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
const sortableColumns: Record<string, Column> = {
name: discounts.name,
discount_type: discounts.discountType,
created_at: discounts.createdAt,
}
let query = db.select().from(discounts).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, discounts.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(discounts).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async listAll(db: PostgresJsDatabase<any>) {
return db
.select()
.from(discounts)
.where(eq(discounts.isActive, true))
.orderBy(discounts.name)
},
async update(db: PostgresJsDatabase<any>, id: string, input: DiscountUpdateInput) {
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.discountValue !== undefined) updates.discountValue = input.discountValue.toString()
if (input.requiresApprovalAbove !== undefined) updates.requiresApprovalAbove = input.requiresApprovalAbove.toString()
if (input.validFrom !== undefined) updates.validFrom = input.validFrom ? new Date(input.validFrom) : null
if (input.validUntil !== undefined) updates.validUntil = input.validUntil ? new Date(input.validUntil) : null
const [discount] = await db
.update(discounts)
.set(updates)
.where(eq(discounts.id, id))
.returning()
return discount ?? null
},
async softDelete(db: PostgresJsDatabase<any>, id: string) {
const [discount] = await db
.update(discounts)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(discounts.id, id))
.returning()
return discount ?? null
},
}

View File

@@ -0,0 +1,114 @@
import { eq, and, count, sum, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { drawerSessions, transactions } from '../db/schema/pos.js'
import { ConflictError, NotFoundError } from '../lib/errors.js'
import type { DrawerOpenInput, DrawerCloseInput, PaginationInput } from '@lunarfront/shared/schemas'
import { withPagination, withSort, paginatedResponse } from '../utils/pagination.js'
export const DrawerService = {
async open(db: PostgresJsDatabase<any>, input: DrawerOpenInput, openedBy: string) {
// Ensure no other open session at this location
if (input.locationId) {
const existing = await this.getOpen(db, input.locationId)
if (existing) {
throw new ConflictError('A drawer session is already open at this location')
}
}
const [session] = await db
.insert(drawerSessions)
.values({
locationId: input.locationId,
openedBy,
openingBalance: input.openingBalance.toString(),
})
.returning()
return session
},
async close(db: PostgresJsDatabase<any>, sessionId: string, input: DrawerCloseInput, closedBy: string) {
const session = await this.getById(db, sessionId)
if (!session) throw new NotFoundError('Drawer session')
if (session.status === 'closed') throw new ConflictError('Drawer session is already closed')
// Calculate expected balance from cash transactions in this drawer session
// Net cash kept = total + rounding_adjustment (change is already accounted for)
const [cashTotals] = await db
.select({
total: sum(transactions.total),
rounding: sum(transactions.roundingAdjustment),
})
.from(transactions)
.where(
and(
eq(transactions.drawerSessionId, sessionId),
eq(transactions.status, 'completed'),
eq(transactions.paymentMethod, 'cash')
)
)
const cashIn = parseFloat(cashTotals?.total ?? '0') + parseFloat(cashTotals?.rounding ?? '0')
const openingBalance = parseFloat(session.openingBalance)
const expectedBalance = openingBalance + cashIn
const closingBalance = input.closingBalance
const overShort = closingBalance - expectedBalance
const [updated] = await db
.update(drawerSessions)
.set({
closedBy,
closingBalance: closingBalance.toString(),
expectedBalance: expectedBalance.toString(),
overShort: overShort.toString(),
denominations: input.denominations,
notes: input.notes,
status: 'closed',
closedAt: new Date(),
})
.where(eq(drawerSessions.id, sessionId))
.returning()
return updated
},
async getOpen(db: PostgresJsDatabase<any>, locationId: string) {
const [session] = await db
.select()
.from(drawerSessions)
.where(
and(
eq(drawerSessions.locationId, locationId),
eq(drawerSessions.status, 'open')
)
)
.limit(1)
return session ?? null
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [session] = await db
.select()
.from(drawerSessions)
.where(eq(drawerSessions.id, id))
.limit(1)
return session ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const sortableColumns: Record<string, Column> = {
opened_at: drawerSessions.openedAt,
closed_at: drawerSessions.closedAt,
status: drawerSessions.status,
}
let query = db.select().from(drawerSessions).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, drawerSessions.openedAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(drawerSessions),
])
return paginatedResponse(data, total, params.page, params.limit)
},
}

View File

@@ -140,6 +140,15 @@ export const ProductService = {
return product ?? null return product ?? null
}, },
async getByUpc(db: PostgresJsDatabase<any>, upc: string) {
const [product] = await db
.select()
.from(products)
.where(and(eq(products.upc, upc), eq(products.isActive, true)))
.limit(1)
return product ?? null
},
async listPriceHistory(db: PostgresJsDatabase<any>, productId: string) { async listPriceHistory(db: PostgresJsDatabase<any>, productId: string) {
return db return db
.select() .select()

View File

@@ -0,0 +1,86 @@
import { eq } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { locations } from '../db/schema/stores.js'
import { AppError } from '../lib/errors.js'
export type TaxCategory = 'goods' | 'service' | 'exempt'
export const TaxService = {
/**
* Get the tax rate for a location, resolved by tax category:
* - "goods" → location.taxRate (default)
* - "service" → location.serviceTaxRate, falls back to taxRate
* - "exempt" → 0
*
* Returns 0 with no warning for exempt items.
* Returns 0 with warning if no rate is configured on the location.
*/
async getRateForLocation(
db: PostgresJsDatabase<any>,
locationId: string,
taxCategory: TaxCategory = 'goods',
): Promise<number> {
if (taxCategory === 'exempt') return 0
const [location] = await db
.select({ taxRate: locations.taxRate, serviceTaxRate: locations.serviceTaxRate })
.from(locations)
.where(eq(locations.id, locationId))
.limit(1)
if (!location) return 0
if (taxCategory === 'service') {
// Use service rate if set, otherwise fall back to goods rate
const rate = location.serviceTaxRate ?? location.taxRate
return rate ? parseFloat(rate) : 0
}
// Default: goods rate
return location.taxRate ? parseFloat(location.taxRate) : 0
},
calculateTax(amount: number, rate: number): number {
return Math.round(amount * rate * 100) / 100
},
/**
* Swedish rounding: round to nearest $0.05 for cash payments.
* Only affects the final total — tax and line items stay exact.
*/
roundToNickel(amount: number): number {
return Math.round(amount * 20) / 20
},
/**
* Map repair line item types to tax categories:
* - "part" → goods (taxable)
* - "labor" → service (may be taxed differently)
* - "flat_rate" → goods (conservative — includes parts)
* - "misc" → goods (default)
*/
repairItemTypeToTaxCategory(itemType: string): TaxCategory {
switch (itemType) {
case 'labor':
return 'service'
case 'part':
case 'flat_rate':
case 'misc':
default:
return 'goods'
}
},
// TODO: Integrate with a real tax rate API (TaxJar, Avalara, etc.)
// Set TAX_API_KEY env var when ready.
async lookupByZip(
_zip: string,
): Promise<{ zip: string; rate: number; state_rate: number; county_rate: number; city_rate: number }> {
if (!process.env.TAX_API_KEY) {
throw new AppError('Tax rate lookup is not configured. Set TAX_API_KEY to enable automatic lookup.', 501)
}
// Placeholder — replace with real API call
throw new AppError('Tax rate lookup not yet implemented', 501)
},
}

View File

@@ -0,0 +1,440 @@
import { eq, and, count, sql, desc, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import {
transactions,
transactionLineItems,
discountAudits,
discounts,
} from '../db/schema/pos.js'
import { products, inventoryUnits } from '../db/schema/inventory.js'
import { companies, locations } from '../db/schema/stores.js'
import { NotFoundError, ValidationError, ConflictError } from '../lib/errors.js'
import { TaxService } from './tax.service.js'
import type {
TransactionCreateInput,
TransactionLineItemCreateInput,
ApplyDiscountInput,
CompleteTransactionInput,
PaginationInput,
} from '@lunarfront/shared/schemas'
import {
withPagination,
withSort,
buildSearchCondition,
paginatedResponse,
} from '../utils/pagination.js'
export const TransactionService = {
async create(db: PostgresJsDatabase<any>, input: TransactionCreateInput, processedBy: string) {
const transactionNumber = await generateTransactionNumber(db)
const [txn] = await db
.insert(transactions)
.values({
transactionNumber,
transactionType: input.transactionType,
locationId: input.locationId,
accountId: input.accountId,
repairTicketId: input.repairTicketId,
repairBatchId: input.repairBatchId,
notes: input.notes,
taxExempt: input.taxExempt,
taxExemptReason: input.taxExemptReason,
processedBy,
})
.returning()
return txn
},
async addLineItem(db: PostgresJsDatabase<any>, transactionId: string, input: TransactionLineItemCreateInput) {
const txn = await this.getById(db, transactionId)
if (!txn) throw new NotFoundError('Transaction')
if (txn.status !== 'pending') throw new ConflictError('Can only add items to pending transactions')
// Resolve tax category from the product (defaults to "goods")
let taxCategory: 'goods' | 'service' | 'exempt' = 'goods'
if (input.productId) {
const [product] = await db
.select({ taxCategory: products.taxCategory })
.from(products)
.where(eq(products.id, input.productId))
.limit(1)
if (product?.taxCategory) {
taxCategory = product.taxCategory as 'goods' | 'service' | 'exempt'
}
}
// Snapshot the tax rate at time of sale
let taxRate = 0
if (!txn.taxExempt && txn.locationId) {
taxRate = await TaxService.getRateForLocation(db, txn.locationId, taxCategory)
}
const lineSubtotal = input.unitPrice * input.qty
const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate)
const lineTotal = lineSubtotal + taxAmount
const [lineItem] = await db
.insert(transactionLineItems)
.values({
transactionId,
productId: input.productId,
inventoryUnitId: input.inventoryUnitId,
description: input.description,
qty: input.qty,
unitPrice: input.unitPrice.toString(),
taxRate: taxRate.toString(),
taxAmount: taxAmount.toString(),
lineTotal: lineTotal.toString(),
})
.returning()
await this.recalculateTotals(db, transactionId)
return lineItem
},
async removeLineItem(db: PostgresJsDatabase<any>, transactionId: string, lineItemId: string) {
const txn = await this.getById(db, transactionId)
if (!txn) throw new NotFoundError('Transaction')
if (txn.status !== 'pending') throw new ConflictError('Can only remove items from pending transactions')
const [deleted] = await db
.delete(transactionLineItems)
.where(
and(
eq(transactionLineItems.id, lineItemId),
eq(transactionLineItems.transactionId, transactionId)
)
)
.returning()
if (!deleted) throw new NotFoundError('Line item')
await this.recalculateTotals(db, transactionId)
return deleted
},
async applyDiscount(
db: PostgresJsDatabase<any>,
transactionId: string,
input: ApplyDiscountInput,
appliedBy: string,
) {
const txn = await this.getById(db, transactionId)
if (!txn) throw new NotFoundError('Transaction')
if (txn.status !== 'pending') throw new ConflictError('Can only apply discounts to pending transactions')
// If applying a predefined discount, check approval threshold
if (input.discountId) {
const [discount] = await db
.select()
.from(discounts)
.where(eq(discounts.id, input.discountId))
.limit(1)
if (!discount) throw new NotFoundError('Discount')
if (
discount.requiresApprovalAbove &&
input.amount > parseFloat(discount.requiresApprovalAbove)
) {
throw new ValidationError('Discount amount exceeds approval threshold — manager approval required')
}
}
// Apply to specific line item or order level
if (input.lineItemId) {
const [lineItem] = await db
.select()
.from(transactionLineItems)
.where(
and(
eq(transactionLineItems.id, input.lineItemId),
eq(transactionLineItems.transactionId, transactionId)
)
)
.limit(1)
if (!lineItem) throw new NotFoundError('Line item')
const originalAmount = parseFloat(lineItem.unitPrice) * lineItem.qty
await db
.update(transactionLineItems)
.set({
discountAmount: input.amount.toString(),
discountReason: input.reason,
})
.where(eq(transactionLineItems.id, input.lineItemId))
// Recalculate line total (subtotal - discount + tax)
const lineSubtotal = originalAmount - input.amount
const taxAmount = TaxService.calculateTax(lineSubtotal, parseFloat(lineItem.taxRate))
const lineTotal = lineSubtotal + taxAmount
await db
.update(transactionLineItems)
.set({
taxAmount: taxAmount.toString(),
lineTotal: lineTotal.toString(),
})
.where(eq(transactionLineItems.id, input.lineItemId))
// Create audit record
await db.insert(discountAudits).values({
transactionId,
transactionLineItemId: input.lineItemId,
discountId: input.discountId,
appliedBy,
originalAmount: originalAmount.toString(),
discountedAmount: input.amount.toString(),
reason: input.reason,
})
}
await this.recalculateTotals(db, transactionId)
},
async recalculateTotals(db: PostgresJsDatabase<any>, transactionId: string) {
const lineItems = await db
.select()
.from(transactionLineItems)
.where(eq(transactionLineItems.transactionId, transactionId))
let subtotal = 0
let discountTotal = 0
let taxTotal = 0
for (const item of lineItems) {
const itemSubtotal = parseFloat(item.unitPrice) * item.qty
subtotal += itemSubtotal
discountTotal += parseFloat(item.discountAmount)
taxTotal += parseFloat(item.taxAmount)
}
const total = subtotal - discountTotal + taxTotal
await db
.update(transactions)
.set({
subtotal: subtotal.toString(),
discountTotal: discountTotal.toString(),
taxTotal: taxTotal.toString(),
total: total.toString(),
updatedAt: new Date(),
})
.where(eq(transactions.id, transactionId))
},
async complete(db: PostgresJsDatabase<any>, transactionId: string, input: CompleteTransactionInput) {
const txn = await this.getById(db, transactionId)
if (!txn) throw new NotFoundError('Transaction')
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending')
// Validate cash payment (with optional nickel rounding)
let changeGiven: string | undefined
let roundingAdjustment = 0
if (input.paymentMethod === 'cash') {
let total = parseFloat(txn.total)
// Apply Swedish rounding if location has cash_rounding enabled
if (txn.locationId) {
const [loc] = await db
.select({ cashRounding: locations.cashRounding })
.from(locations)
.where(eq(locations.id, txn.locationId))
.limit(1)
if (loc?.cashRounding) {
const rounded = TaxService.roundToNickel(total)
roundingAdjustment = Math.round((rounded - total) * 100) / 100
total = rounded
}
}
if (!input.amountTendered || input.amountTendered < total) {
throw new ValidationError('Amount tendered must be >= transaction total for cash payments')
}
changeGiven = (input.amountTendered - total).toString()
}
// Update inventory for each line item
const lineItems = await db
.select()
.from(transactionLineItems)
.where(eq(transactionLineItems.transactionId, transactionId))
for (const item of lineItems) {
if (item.inventoryUnitId) {
// Serialized item — mark as sold
await db
.update(inventoryUnits)
.set({ status: 'sold' })
.where(eq(inventoryUnits.id, item.inventoryUnitId))
} else if (item.productId) {
// Non-serialized — decrement qty_on_hand
await db
.update(products)
.set({
qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`,
updatedAt: new Date(),
})
.where(eq(products.id, item.productId))
}
}
const [completed] = await db
.update(transactions)
.set({
status: 'completed',
paymentMethod: input.paymentMethod,
amountTendered: input.amountTendered?.toString(),
changeGiven,
roundingAdjustment: roundingAdjustment.toString(),
checkNumber: input.checkNumber,
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(transactions.id, transactionId))
.returning()
return completed
},
async void(db: PostgresJsDatabase<any>, transactionId: string, _voidedBy: string) {
const txn = await this.getById(db, transactionId)
if (!txn) throw new NotFoundError('Transaction')
if (txn.status !== 'pending') throw new ConflictError('Can only void pending transactions')
// Restore inventory (in case items were reserved, though we only decrement on complete)
const [voided] = await db
.update(transactions)
.set({
status: 'voided',
updatedAt: new Date(),
})
.where(eq(transactions.id, transactionId))
.returning()
return voided
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [txn] = await db
.select()
.from(transactions)
.where(eq(transactions.id, id))
.limit(1)
if (!txn) return null
const lineItems = await db
.select()
.from(transactionLineItems)
.where(eq(transactionLineItems.transactionId, id))
return { ...txn, lineItems }
},
async getReceipt(db: PostgresJsDatabase<any>, id: string) {
const txn = await this.getById(db, id)
if (!txn) throw new NotFoundError('Transaction')
// Get company info
const [company] = await db.select().from(companies).limit(1)
// Get location info if available
let location = null
if (txn.locationId) {
const [loc] = await db
.select()
.from(locations)
.where(eq(locations.id, txn.locationId))
.limit(1)
location = loc ?? null
}
return {
transaction: txn,
company: company
? {
name: company.name,
phone: company.phone,
email: company.email,
address: company.address,
}
: null,
location: location
? {
name: location.name,
phone: location.phone,
email: location.email,
address: location.address,
}
: null,
}
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: {
status?: string
transactionType?: string
locationId?: string
}) {
const conditions: ReturnType<typeof eq>[] = []
if (params.q) {
conditions.push(buildSearchCondition(params.q, [transactions.transactionNumber])!)
}
if (filters?.status) {
conditions.push(eq(transactions.status, filters.status as any))
}
if (filters?.transactionType) {
conditions.push(eq(transactions.transactionType, filters.transactionType as any))
}
if (filters?.locationId) {
conditions.push(eq(transactions.locationId, filters.locationId))
}
const where = conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : and(...conditions)
const sortableColumns: Record<string, Column> = {
transaction_number: transactions.transactionNumber,
total: transactions.total,
status: transactions.status,
created_at: transactions.createdAt,
completed_at: transactions.completedAt,
}
let query = db.select().from(transactions).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, transactions.createdAt)
query = withPagination(query, params.page, params.limit)
const countQuery = where
? db.select({ total: count() }).from(transactions).where(where)
: db.select({ total: count() }).from(transactions)
const [data, [{ total }]] = await Promise.all([query, countQuery])
return paginatedResponse(data, total, params.page, params.limit)
},
}
async function generateTransactionNumber(db: PostgresJsDatabase<any>): Promise<string> {
const today = new Date()
const dateStr = today.toISOString().slice(0, 10).replace(/-/g, '')
const prefix = `TXN-${dateStr}-`
// Find the highest sequence number for today
const [latest] = await db
.select({ transactionNumber: transactions.transactionNumber })
.from(transactions)
.where(sql`${transactions.transactionNumber} LIKE ${prefix + '%'}`)
.orderBy(desc(transactions.transactionNumber))
.limit(1)
let seq = 1
if (latest?.transactionNumber) {
const lastSeq = parseInt(latest.transactionNumber.replace(prefix, ''), 10)
if (!isNaN(lastSeq)) seq = lastSeq + 1
}
return `${prefix}${seq.toString().padStart(4, '0')}`
}

View File

@@ -0,0 +1,344 @@
import { describe, it, expect } from 'bun:test'
import {
TransactionCreateSchema,
TransactionLineItemCreateSchema,
ApplyDiscountSchema,
CompleteTransactionSchema,
DiscountCreateSchema,
DiscountUpdateSchema,
DrawerOpenSchema,
DrawerCloseSchema,
TransactionType,
TransactionStatus,
PaymentMethod,
DiscountType,
DiscountAppliesTo,
DrawerStatus,
TaxCategory,
} from '../../src/schemas/pos.schema.js'
// ─── Enums ───────────────────────────────────────────────────────────────────
describe('POS enums', () => {
it('TransactionType accepts valid values', () => {
expect(TransactionType.parse('sale')).toBe('sale')
expect(TransactionType.parse('repair_payment')).toBe('repair_payment')
expect(TransactionType.parse('refund')).toBe('refund')
})
it('TransactionType rejects invalid value', () => {
expect(() => TransactionType.parse('layaway')).toThrow()
})
it('TransactionStatus accepts valid values', () => {
expect(TransactionStatus.parse('pending')).toBe('pending')
expect(TransactionStatus.parse('voided')).toBe('voided')
})
it('PaymentMethod accepts valid values', () => {
expect(PaymentMethod.parse('cash')).toBe('cash')
expect(PaymentMethod.parse('card_present')).toBe('card_present')
expect(PaymentMethod.parse('account_charge')).toBe('account_charge')
})
it('TaxCategory accepts valid values', () => {
expect(TaxCategory.parse('goods')).toBe('goods')
expect(TaxCategory.parse('service')).toBe('service')
expect(TaxCategory.parse('exempt')).toBe('exempt')
})
it('TaxCategory rejects invalid value', () => {
expect(() => TaxCategory.parse('luxury')).toThrow()
})
})
// ─── TransactionCreateSchema ─────────────────────────────────────────────────
describe('TransactionCreateSchema', () => {
it('parses minimal valid input', () => {
const result = TransactionCreateSchema.parse({ transactionType: 'sale' })
expect(result.transactionType).toBe('sale')
expect(result.taxExempt).toBe(false)
})
it('parses full input with optional fields', () => {
const result = TransactionCreateSchema.parse({
transactionType: 'repair_payment',
locationId: '10000000-1000-4000-8000-000000000001',
accountId: '10000000-1000-4000-8000-000000000002',
taxExempt: true,
taxExemptReason: 'Non-profit',
notes: 'Customer walkup',
})
expect(result.transactionType).toBe('repair_payment')
expect(result.taxExempt).toBe(true)
expect(result.taxExemptReason).toBe('Non-profit')
})
it('rejects missing transactionType', () => {
expect(() => TransactionCreateSchema.parse({})).toThrow()
})
it('coerces empty string locationId to undefined', () => {
const result = TransactionCreateSchema.parse({ transactionType: 'sale', locationId: '' })
expect(result.locationId).toBeUndefined()
})
it('rejects invalid UUID for locationId', () => {
expect(() =>
TransactionCreateSchema.parse({ transactionType: 'sale', locationId: 'not-a-uuid' })
).toThrow()
})
})
// ─── TransactionLineItemCreateSchema ─────────────────────────────────────────
describe('TransactionLineItemCreateSchema', () => {
it('parses valid line item', () => {
const result = TransactionLineItemCreateSchema.parse({
description: 'Violin Strings',
qty: 2,
unitPrice: 12.99,
})
expect(result.description).toBe('Violin Strings')
expect(result.qty).toBe(2)
expect(result.unitPrice).toBe(12.99)
})
it('defaults qty to 1', () => {
const result = TransactionLineItemCreateSchema.parse({
description: 'Capo',
unitPrice: 19.99,
})
expect(result.qty).toBe(1)
})
it('coerces string unitPrice to number', () => {
const result = TransactionLineItemCreateSchema.parse({
description: 'Pick',
unitPrice: '5.99',
})
expect(result.unitPrice).toBe(5.99)
})
it('rejects empty description', () => {
expect(() =>
TransactionLineItemCreateSchema.parse({ description: '', unitPrice: 10 })
).toThrow()
})
it('rejects description over 255 chars', () => {
expect(() =>
TransactionLineItemCreateSchema.parse({ description: 'x'.repeat(256), unitPrice: 10 })
).toThrow()
})
it('rejects negative unitPrice', () => {
expect(() =>
TransactionLineItemCreateSchema.parse({ description: 'Bad', unitPrice: -1 })
).toThrow()
})
it('rejects qty of 0', () => {
expect(() =>
TransactionLineItemCreateSchema.parse({ description: 'Zero', qty: 0, unitPrice: 10 })
).toThrow()
})
it('rejects non-integer qty', () => {
expect(() =>
TransactionLineItemCreateSchema.parse({ description: 'Frac', qty: 1.5, unitPrice: 10 })
).toThrow()
})
})
// ─── ApplyDiscountSchema ─────────────────────────────────────────────────────
describe('ApplyDiscountSchema', () => {
it('parses valid discount application', () => {
const result = ApplyDiscountSchema.parse({
amount: 10,
reason: 'Employee discount',
lineItemId: '10000000-1000-4000-8000-000000000001',
})
expect(result.amount).toBe(10)
expect(result.reason).toBe('Employee discount')
})
it('rejects missing reason', () => {
expect(() => ApplyDiscountSchema.parse({ amount: 5 })).toThrow()
})
it('rejects empty reason', () => {
expect(() => ApplyDiscountSchema.parse({ amount: 5, reason: '' })).toThrow()
})
it('rejects negative amount', () => {
expect(() => ApplyDiscountSchema.parse({ amount: -1, reason: 'Nope' })).toThrow()
})
it('allows zero amount', () => {
const result = ApplyDiscountSchema.parse({ amount: 0, reason: 'Remove discount' })
expect(result.amount).toBe(0)
})
})
// ─── CompleteTransactionSchema ───────────────────────────────────────────────
describe('CompleteTransactionSchema', () => {
it('parses cash payment with amount tendered', () => {
const result = CompleteTransactionSchema.parse({
paymentMethod: 'cash',
amountTendered: 50,
})
expect(result.paymentMethod).toBe('cash')
expect(result.amountTendered).toBe(50)
})
it('parses card payment without amount tendered', () => {
const result = CompleteTransactionSchema.parse({ paymentMethod: 'card_present' })
expect(result.paymentMethod).toBe('card_present')
expect(result.amountTendered).toBeUndefined()
})
it('parses check payment with check number', () => {
const result = CompleteTransactionSchema.parse({
paymentMethod: 'check',
checkNumber: '1234',
})
expect(result.checkNumber).toBe('1234')
})
it('rejects missing paymentMethod', () => {
expect(() => CompleteTransactionSchema.parse({})).toThrow()
})
it('rejects invalid payment method', () => {
expect(() => CompleteTransactionSchema.parse({ paymentMethod: 'bitcoin' })).toThrow()
})
it('rejects check number over 50 chars', () => {
expect(() =>
CompleteTransactionSchema.parse({ paymentMethod: 'check', checkNumber: 'x'.repeat(51) })
).toThrow()
})
})
// ─── DiscountCreateSchema ────────────────────────────────────────────────────
describe('DiscountCreateSchema', () => {
it('parses valid discount', () => {
const result = DiscountCreateSchema.parse({
name: '10% Off',
discountType: 'percent',
discountValue: 10,
})
expect(result.name).toBe('10% Off')
expect(result.appliesTo).toBe('line_item') // default
expect(result.isActive).toBe(true) // default
})
it('rejects missing name', () => {
expect(() => DiscountCreateSchema.parse({ discountType: 'fixed', discountValue: 5 })).toThrow()
})
it('rejects empty name', () => {
expect(() =>
DiscountCreateSchema.parse({ name: '', discountType: 'fixed', discountValue: 5 })
).toThrow()
})
it('accepts order-level discount', () => {
const result = DiscountCreateSchema.parse({
name: 'Order Disc',
discountType: 'fixed',
discountValue: 20,
appliesTo: 'order',
})
expect(result.appliesTo).toBe('order')
})
it('accepts optional approval threshold', () => {
const result = DiscountCreateSchema.parse({
name: 'Big Disc',
discountType: 'percent',
discountValue: 50,
requiresApprovalAbove: 25,
})
expect(result.requiresApprovalAbove).toBe(25)
})
})
// ─── DiscountUpdateSchema ────────────────────────────────────────────────────
describe('DiscountUpdateSchema', () => {
it('accepts partial update', () => {
const result = DiscountUpdateSchema.parse({ name: 'Updated Name' })
expect(result.name).toBe('Updated Name')
expect(result.discountType).toBeUndefined()
})
it('accepts empty object (defaults still apply)', () => {
const result = DiscountUpdateSchema.parse({})
expect(result.appliesTo).toBe('line_item')
expect(result.isActive).toBe(true)
expect(result.name).toBeUndefined()
})
})
// ─── DrawerOpenSchema ────────────────────────────────────────────────────────
describe('DrawerOpenSchema', () => {
it('parses valid drawer open', () => {
const result = DrawerOpenSchema.parse({
locationId: '10000000-1000-4000-8000-000000000001',
openingBalance: 200,
})
expect(result.openingBalance).toBe(200)
})
it('allows opening without location (floating drawer)', () => {
const result = DrawerOpenSchema.parse({ openingBalance: 100 })
expect(result.locationId).toBeUndefined()
})
it('rejects negative opening balance', () => {
expect(() => DrawerOpenSchema.parse({ openingBalance: -50 })).toThrow()
})
it('coerces string opening balance', () => {
const result = DrawerOpenSchema.parse({ openingBalance: '150' })
expect(result.openingBalance).toBe(150)
})
})
// ─── DrawerCloseSchema ───────────────────────────────────────────────────────
describe('DrawerCloseSchema', () => {
it('parses valid drawer close', () => {
const result = DrawerCloseSchema.parse({ closingBalance: 250 })
expect(result.closingBalance).toBe(250)
})
it('accepts denominations', () => {
const result = DrawerCloseSchema.parse({
closingBalance: 100,
denominations: { ones: 20, fives: 10, tens: 3 },
})
expect(result.denominations!.ones).toBe(20)
})
it('accepts notes', () => {
const result = DrawerCloseSchema.parse({ closingBalance: 100, notes: 'Short $5' })
expect(result.notes).toBe('Short $5')
})
it('coerces empty notes to undefined', () => {
const result = DrawerCloseSchema.parse({ closingBalance: 100, notes: '' })
expect(result.notes).toBeUndefined()
})
it('rejects negative closing balance', () => {
expect(() => DrawerCloseSchema.parse({ closingBalance: -10 })).toThrow()
})
})

View File

@@ -163,3 +163,31 @@ export type {
LessonPlanTemplateUpdateInput, LessonPlanTemplateUpdateInput,
TemplateInstantiateInput, TemplateInstantiateInput,
} from './lessons.schema.js' } from './lessons.schema.js'
export {
TransactionType,
TransactionStatus,
PaymentMethod,
DiscountType,
DiscountAppliesTo,
DrawerStatus,
TaxCategory,
TransactionCreateSchema,
TransactionLineItemCreateSchema,
ApplyDiscountSchema,
CompleteTransactionSchema,
DiscountCreateSchema,
DiscountUpdateSchema,
DrawerOpenSchema,
DrawerCloseSchema,
} from './pos.schema.js'
export type {
TransactionCreateInput,
TransactionLineItemCreateInput,
ApplyDiscountInput,
CompleteTransactionInput,
DiscountCreateInput,
DiscountUpdateInput,
DrawerOpenInput,
DrawerCloseInput,
} from './pos.schema.js'

View File

@@ -0,0 +1,114 @@
import { z } from 'zod'
/** Coerce empty strings to undefined — solves HTML form inputs sending '' for blank optional fields */
function opt<T extends z.ZodTypeAny>(schema: T) {
return z.preprocess((v) => (v === '' ? undefined : v), schema.optional())
}
// --- Enum values (must match pgEnum definitions in pos.ts) ---
export const TransactionType = z.enum([
'sale',
'repair_payment',
'rental_deposit',
'account_payment',
'refund',
])
export type TransactionType = z.infer<typeof TransactionType>
export const TransactionStatus = z.enum(['pending', 'completed', 'voided', 'refunded'])
export type TransactionStatus = z.infer<typeof TransactionStatus>
export const PaymentMethod = z.enum([
'cash',
'card_present',
'card_keyed',
'check',
'account_charge',
])
export type PaymentMethod = z.infer<typeof PaymentMethod>
export const DiscountType = z.enum(['percent', 'fixed'])
export type DiscountType = z.infer<typeof DiscountType>
export const DiscountAppliesTo = z.enum(['order', 'line_item', 'category'])
export type DiscountAppliesTo = z.infer<typeof DiscountAppliesTo>
export const DrawerStatus = z.enum(['open', 'closed'])
export type DrawerStatus = z.infer<typeof DrawerStatus>
export const TaxCategory = z.enum(['goods', 'service', 'exempt'])
export type TaxCategory = z.infer<typeof TaxCategory>
// --- Transaction schemas ---
export const TransactionCreateSchema = z.object({
transactionType: TransactionType,
locationId: opt(z.string().uuid()),
accountId: opt(z.string().uuid()),
repairTicketId: opt(z.string().uuid()),
repairBatchId: opt(z.string().uuid()),
notes: opt(z.string()),
taxExempt: z.boolean().default(false),
taxExemptReason: opt(z.string()),
})
export type TransactionCreateInput = z.infer<typeof TransactionCreateSchema>
export const TransactionLineItemCreateSchema = z.object({
productId: opt(z.string().uuid()),
inventoryUnitId: opt(z.string().uuid()),
description: z.string().min(1).max(255),
qty: z.number().int().min(1).default(1),
unitPrice: z.coerce.number().min(0),
})
export type TransactionLineItemCreateInput = z.infer<typeof TransactionLineItemCreateSchema>
export const ApplyDiscountSchema = z.object({
discountId: opt(z.string().uuid()),
amount: z.coerce.number().min(0),
reason: z.string().min(1),
lineItemId: opt(z.string().uuid()),
})
export type ApplyDiscountInput = z.infer<typeof ApplyDiscountSchema>
export const CompleteTransactionSchema = z.object({
paymentMethod: PaymentMethod,
amountTendered: z.coerce.number().min(0).optional(),
checkNumber: opt(z.string().max(50)),
})
export type CompleteTransactionInput = z.infer<typeof CompleteTransactionSchema>
// --- Discount schemas ---
export const DiscountCreateSchema = z.object({
name: z.string().min(1).max(255),
discountType: DiscountType,
discountValue: z.coerce.number().min(0),
appliesTo: DiscountAppliesTo.default('line_item'),
locationId: opt(z.string().uuid()),
requiresApprovalAbove: z.coerce.number().min(0).optional(),
isActive: z.boolean().default(true),
validFrom: opt(z.string()),
validUntil: opt(z.string()),
})
export type DiscountCreateInput = z.infer<typeof DiscountCreateSchema>
export const DiscountUpdateSchema = DiscountCreateSchema.partial()
export type DiscountUpdateInput = z.infer<typeof DiscountUpdateSchema>
// --- Drawer schemas ---
export const DrawerOpenSchema = z.object({
locationId: opt(z.string().uuid()),
openingBalance: z.coerce.number().min(0),
})
export type DrawerOpenInput = z.infer<typeof DrawerOpenSchema>
export const DrawerCloseSchema = z.object({
closingBalance: z.coerce.number().min(0),
denominations: z
.record(z.string(), z.number())
.optional(),
notes: opt(z.string()),
})
export type DrawerCloseInput = z.infer<typeof DrawerCloseSchema>