Compare commits

...
10 Commits
Author SHA1 Message Date
Krikorios 654b524f1b Fix RBAC, user creation and update components 2026-05-21 20:34:57 +03:00
Krikorios 1f23102050 Prepare shop-ready deployment docs and fix transaction modal UI 2026-05-20 19:47:55 +03:00
Krikorios 1896cbdd11 Add cash management schema and immediate variance alerts 2026-05-06 10:51:55 +03:00
ars128 1a3de58de6 Update README.md 2025-05-28 21:47:18 +05:30
ars128 66955137ce Update LoginPage.tsx 2025-05-28 21:06:04 +05:30
ars128 c7e29fac6d Update LoginPage.tsx 2025-05-28 21:02:43 +05:30
ars128 341a0de9ed Update OutstandingReportDashboard.tsx 2025-05-28 20:50:05 +05:30
ars128 dcde85e666 Update LoginPage.tsx 2025-05-28 20:40:33 +05:30
gpt-engineer-app[bot] a8eec0e383 Fix: Resolve login issue and TypeScript errors
The AI addressed the persistent login issue by updating the `useSupabaseEmployeeData` hook and the `LoginPage` component, and instructed the user to disable email confirmation and update redirect URLs in Supabase settings. The commit also fixes TypeScript errors.
2025-05-28 05:07:11 +00:00
gpt-engineer-app[bot] 49cd117a2f Fix: Update Supabase URL and login issue
The user updated the site URL and disabled email confirmation, but still encountered a "email not confirmed" error. This commit addresses the issue by ensuring the correct Supabase URL is used and resolving any remaining login problems.
2025-05-28 04:29:32 +00:00
116 changed files with 18657 additions and 4701 deletions
+13
View File
@@ -0,0 +1,13 @@
# Copy this file to .env before running docker compose.
# Do NOT commit your real .env file.
# Postgres container password (use a strong random string)
POSTGRES_PASSWORD=replace-with-strong-password
# Database name used by the app
POSTGRES_DB=crm_omt
# Seed admin user created on first DB initialization
ADMIN_EMAIL=admin@local.test
ADMIN_PASSWORD=ChangeMe123!
ADMIN_NAME=Owner
+6
View File
@@ -22,3 +22,9 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# production secrets
.env
server/.env
server/.env.production
backups/
+252
View File
@@ -0,0 +1,252 @@
# CLAUDE.md
Persistent context for Claude / Copilot sessions on this repository.
Read this **first** before making changes.
---
## 1. Project Idea
**CRM OMT — Cash Collection Management System** is a multi-shop POS / cash
control / reconciliation web app aimed at the typical Lebanese cell-phone /
mixed-retail shop.
### The real-world problem we're solving
Most cell shops in Lebanon don't just sell phones and do repairs — they also
operate an **OMT counter and/or a Whish counter** (plus Alfa/Touch recharge,
EDL bill payments, FX swap between USD and LBP, etc.) as a side service for
walk-in customers. That side service moves a lot of cash through the till
every day, in two currencies, across multiple employees and shifts.
What owners actually struggle with:
- **Cash shortages / "incompatible cash" at end of shift** — the drawer doesn't
match what the system says it should hold.
- **No clear accountability per cashier / per shift** — when money is missing,
it's not obvious *whose* shift it disappeared on.
- **Mixed streams in one drawer** — repair income, item sales, OMT send/receive,
Whish in/out, recharges, bill payments, FX swaps — all flowing through the
same physical cash, in USD *and* LBP, with no separation.
- **Carry-forward shortfalls** — a cashier comes up short one day; the owner
needs that shortfall to roll forward and be cleared by future deposits, not
silently forgotten.
- **Owner has no live visibility** — they want to know, at any moment, how much
cash *should* be in each till and in the safe, who owes what, and where the
variance is.
This app addresses exactly that: it gives the **shop owner** a tool to track
every money movement (repair, sale, OMT, Whish, recharge, bill, FX, deposit,
withdrawal, refund, void) per **employee** and per **shift**, enforce the
accounting rules in the database (atomic sale coupling, sign guards, void
reverses movements, idempotency), and surface variance + outstanding balances
so losses are caught the same day instead of weeks later.
### Feature areas
- **Shift control** — open/close shifts, opening floats, midday drops, end-of-day variance.
- **POS / Transaction entry** — record service sales, fees, FX swaps, customer KYC.
- **Cashier tools** — deposits, withdrawals, refunds, voids, overrides.
- **Manager console** — approvals, fee schedule, fx rates, safe & bank ledger, seed data.
- **User management** — roles (admin / manager / cashier), assignments per shop & shift.
- **Reporting** — outstanding employee balances (carry-forward shortfall logic),
detailed employee payment reports, reconciliation views.
The accounting model is enforced server-side in PostgreSQL: atomic sale coupling,
cash movement sign guard, void reverses movements, idempotency keys, RLS by
shop/role, etc. (See `supabase/migrations/00xx_*.sql`.)
---
## 2. Origin Note — Supabase ➜ self-hosted Postgres
> The project was originally scaffolded on **Supabase** (Lovable / `vite_react_shadcn_ts`
> template, `@supabase/supabase-js` client, `auth.users`, RLS using `request.jwt.claim.*`).
>
> It has since been **transformed into a self-hosted PostgreSQL stack**:
>
> - **DB:** local `postgres:16-alpine` via `docker-compose.yml`, data in named volume `dbdata`.
> Schema is the original Supabase migrations under [`supabase/migrations/`](supabase/migrations/),
> replayed by [`server/db/init/01_run_migrations.sh`](server/db/init/01_run_migrations.sh).
> - **Auth shim:** [`server/db/init/00_auth_shim.sql`](server/db/init/00_auth_shim.sql)
> recreates the `auth.users` table + `auth.uid()` / `auth.role()` / `auth.jwt()` SQL
> helpers that the migrations expect, so the original RLS policies keep working.
> - **API:** a small Express server at [`server/src/index.js`](server/src/index.js)
> replaces PostgREST + GoTrue. It issues JWTs via `bcrypt` + `jsonwebtoken`,
> then on every request opens a pooled connection and runs:
> ```sql
> SELECT set_config('request.jwt.claim.sub', $user_id, true);
> SELECT set_config('request.jwt.claim.role', $role, true);
> SELECT set_config('request.jwt.claims', $claims, true);
> SET LOCAL ROLE authenticated;
> ```
> so RLS continues to evaluate exactly as it did on Supabase.
> - **Frontend shim:** [`src/integrations/supabase/client.ts`](src/integrations/supabase/client.ts)
> is no longer the real Supabase JS client — it is a **drop-in shim** backed by
> [`src/lib/api.ts`](src/lib/api.ts) that exposes the same surface
> (`supabase.auth.*`, `supabase.rpc(...)`, `supabase.from(view).select().eq(...)`).
> This is why existing components keep importing `@/integrations/supabase/client`
> even though there is no Supabase anymore.
- **`@supabase/supabase-js`** has been removed from the project. The shim at
`@/integrations/supabase/client` is now the only "supabase" surface.
> - **Seed admin** is created by [`server/db/init/99_seed_admin.sh`](server/db/init/99_seed_admin.sh)
> from `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` env vars in `docker-compose.yml`.
**Implication for any future work:** treat `supabase/*` as the **source of truth
for the schema only**. Do not reintroduce calls to a hosted Supabase. New
endpoints must be added to `server/src/index.js` (and, if used as RPCs, exposed
via `app.<fn>(...)` SQL functions so they go through the generic `/rpc/:fn` route).
---
## 3. Repository Layout
```
cash-collection-management-system/
├── docker-compose.yml # postgres:16-alpine + volume + init scripts
├── index.html # Vite entry
├── vite.config.ts
├── package.json # frontend (Vite + React 18 + TS + shadcn/ui + tailwind)
├── server/
│ ├── package.json # express, pg, bcrypt, jsonwebtoken, cors, dotenv
│ ├── .env(.example) # DATABASE_URL, JWT_SECRET, CORS_ORIGIN, PORT
│ ├── src/index.js # the entire Express API (auth, /rpc/:fn, /from/:view, employees…)
│ └── db/init/ # postgres docker-entrypoint-initdb.d
│ ├── 00_auth_shim.sql # recreates auth.users + auth.* helpers
│ ├── 01_run_migrations.sh # replays /sql/migrations/*.sql in order
│ ├── 50_employee_payments.sql # extra app-layer tables for the payment report
│ └── 99_seed_admin.sh # creates first admin from env vars
├── supabase/
│ ├── config.toml # legacy, unused at runtime
│ └── migrations/ # 0001…0026 — schema + RLS + RPCs (source of truth)
├── src/
│ ├── main.tsx, App.tsx, index.css
│ ├── pages/ # Index.tsx (tabbed shell), NotFound.tsx
│ ├── components/ # Feature components (see §4)
│ │ └── ui/ # shadcn primitives — only the ones actually used
│ ├── hooks/
│ │ ├── useAuth.tsx
│ │ ├── useSupabaseEmployeeData.ts # primary data hook (reads/writes via api shim)
│ │ ├── useEmployeeData.ts # legacy adapter, kept for EmployeePaymentReport
│ │ └── use-toast.ts
│ ├── integrations/supabase/
│ │ └── client.ts # SHIM over src/lib/api.ts — NOT real supabase-js
│ └── lib/
│ ├── api.ts # fetch wrapper around the Express server
│ ├── services.ts # POS service catalogue (OMT_SEND, WHISH_SEND, …)
│ ├── currency.ts # USD/LBP conversion + formatting
│ └── utils.ts # cn() helper
└── docs/
└── THREAT_MODEL.md
```
---
## 4. Feature Components → DB
| Component | Talks to |
| ------------------------------------- | ---------------------------------------------------------- |
| `LoginPage` | `POST /auth/login` (Express) → JWT in localStorage |
| `OwnerOverview` | `v_owner_dashboard`, `v_z_report`, `v_employee_scorecard_30d`, `alerts` (read), `ack_alert` RPC |
| `ShiftControl` | `supabase.rpc(...)` shift open/declare/finalize + Expected/Counted/Δ panel from `finalize_close` |
| `TransactionEntry` | `supabase.rpc(...)` atomic sale + cash movement RPCs |
| `CashierTools` | `supabase.rpc(...)` deposits / withdrawals / refunds |
| `ManagerConsole` | RPCs for fee schedule, fx rates, safe/bank ledger, seeds |
| `UserManagement` | `useSupabaseEmployeeData` + admin RPCs (`get_shop_users`) |
| `OutstandingReportDashboard` | `useSupabaseEmployeeData` (`/employees`, `/employee_transactions`) |
| `DetailedEmployeePaymentReport` | same |
| `EmployeePaymentReport` | `useEmployeeData` (legacy adapter over the same data) |
| `AdminDataEntryModal` | `useSupabaseEmployeeData.addTransaction(...)` |
---
## 5. How to Run
```bash
# one-time
cp server/.env.example server/.env # edit JWT_SECRET if exposed
npm install
npm --prefix server install
# day-to-day (DB + API + Web all together)
npm run dev:all
# DB → docker container crm_omt_db on :5432
# API → node server on :4000
# WEB → vite on :5173 (or :8080)
# build frontend
npm run build
# wipe & rebuild DB (re-runs all migrations + seeds admin)
npm run db:reset
```
Default seed admin (override via env in `docker-compose.yml`):
- email: `admin@local.test`
- password: `ChangeMe123!`
---
## 6. Conventions / Gotchas
- **Don't import `@supabase/supabase-js` directly.** Use `@/integrations/supabase/client`
(the shim) or `@/lib/api` (raw). The package will be removed.
- **Don't put business logic in the Express server.** All money-touching logic
must live in SQL functions under the `app.*` schema (see migrations 00180026)
and be invoked through `/rpc/:fn`. The Express layer only authenticates and
forwards args.
- **RLS depends on JWT claims being set per-connection.** Any new endpoint that
hits a tenant-scoped table must use `withUserClient(req, ...)` in
`server/src/index.js`, not `pool.query` directly.
- **New views exposed via `supabase.from(view)`** must be added to `ALLOWED_VIEWS`
in `server/src/index.js`.
- **shadcn/ui:** only the components actually imported by feature code live in
`src/components/ui/`. If you need another primitive, add it back from
https://ui.shadcn.com — don't restore a kitchen-sink set.
- **Currencies:** USD is the canonical store; LBP is derived via
`getUsdToLbpRate()` in `src/lib/currency.ts`.
---
## 7. Recent Cleanup (2026-05)
**Cleanup pass:**
- Root one-off codegen scripts: `rewrite_pos.py`, `update_assign_shift.py`,
`update_shift_control.py`, `update_shiftcontrol_rpc.py`.
- `bun.lockb` (project uses npm).
- `src/App.css` (Vite template leftover, not imported).
- `src/integrations/supabase/types.ts` (Supabase-generated types, unused since the shim).
- `src/hooks/use-mobile.tsx` (only consumed by the now-removed `sidebar` UI).
- Unused shadcn primitives in `src/components/ui/`:
`accordion, alert, alert-dialog, aspect-ratio, avatar, badge, breadcrumb,
carousel, chart, checkbox, collapsible, command, context-menu, drawer,
dropdown-menu, form, hover-card, input-otp, menubar, navigation-menu,
pagination, progress, radio-group, resizable, scroll-area, separator,
sheet, sidebar, skeleton, slider, switch, toggle, toggle-group`.
- `@supabase/supabase-js` uninstalled.
**Owner-facing additions (against the stated problem):**
- `ShiftControl` now displays an **Expected / Counted / Δ** panel right after
finalize-close, in USD and LBP, color-coded by short / over / match.
- New **`OwnerOverview`** component, wired as the default tab for `admin`
users. Reads:
- `app.v_owner_dashboard` — per-shop open shifts, open alerts, today's gross.
- `app.v_z_report` — recent closed-shift variances.
- `app.v_employee_scorecard_30d` — 30-day per-cashier variance + voids.
- `app.alerts` — open alerts with one-click acknowledge via `app.ack_alert`.
- Exposed those views/tables in `ALLOWED_VIEWS` in `server/src/index.js`.
RLS continues to scope rows.
**Known follow-ups still on the list:**
- Add `WHISH_RECEIVE` (needs new migration: service row + record-receive RPC
+ details table or a generic receive path). Today only `WHISH_SEND` is wired.
- Roll real shift variance into the per-cashier outstanding/carry-forward
ledger so the Outstanding Report reflects POS reality, not just manual entries.
- Cashier-side "live drawer" widget while a shift is open
(expected-vs-recorded by stream). Needs a small `app.live_drawer(p_shift_id)` RPC.
- Inventory + repair-ticket UI (DB seeds exist via `0014_product_catalog.sql`).
`npm run build` is green.
+95 -44
View File
@@ -1,73 +1,124 @@
# Welcome to your Lovable project
# CRM OMT — Cash Collection Management System
## Project info
A multi-shop POS / cash control / reconciliation web app for the typical
Lebanese cell-phone or mixed-retail shop that also runs an OMT and/or Whish
counter alongside repair and item sales.
**URL**: https://lovable.dev/projects/a9d33dc6-ac68-4ccf-a697-7b7f5a0390bf
## The problem
## How can I edit this code?
Most Lebanese cell shops don't just sell phones and do repairs — they also act
as **OMT and/or Whish agents**, sell **Alfa / Touch / Ogero** recharges, take
**EDL** bill payments, and swap between USD and LBP. All of that money flows
through one drawer, in two currencies, across multiple employees and shifts,
mixed with goods and repair income.
There are several ways of editing your application.
The result for the owner is the recurring "incompatible cash" pain: at the end
of the day the drawer doesn't match what the system *should* hold, and there
is no clear accountability per cashier or per shift.
**Use Lovable**
## What this app gives the owner
Simply visit the [Lovable Project](https://lovable.dev/projects/a9d33dc6-ac68-4ccf-a697-7b7f5a0390bf) and start prompting.
- **Per-employee, per-shift accountability** with a blind close: the cashier
declares the cash count, the system computes what was expected, and any
variance is recorded against that exact shift.
- **Live variance panel** at end-of-shift (Expected / Counted / Δ in USD and LBP).
- **Owner overview tab** — open shifts, open alerts, today's gross, recent
closed-shift variances, and a 30-day cashier scorecard.
- **All money streams in one ledger** — OMT send/receive, Whish, Alfa/Touch/Ogero
recharge, EDL bill, FX swap, deposits/withdrawals, refunds, voids, mid-day
safe drops, repair, goods sale.
- **DB-enforced accounting**: atomic sale coupling, cash-movement sign guards,
voids reverse movements, idempotency keys, RLS by shop and role.
- **Alerts pipeline** for chronic shorts, void spikes, override spikes,
reconciliation backlog, after-hours activity, voucher write-offs, stock
shrinkage.
Changes made via Lovable will be committed automatically to this repo.
## Architecture
**Use your preferred IDE**
The schema started life on Supabase but the app **no longer uses Supabase at
runtime**. It runs against a self-hosted Postgres + a thin Express API:
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
- **DB:** `postgres:16-alpine` via [docker-compose.yml](docker-compose.yml).
The Supabase migrations under [`supabase/migrations/`](supabase/migrations/)
are replayed on first boot by [`server/db/init/01_run_migrations.sh`](server/db/init/01_run_migrations.sh),
preceded by an `auth.users` shim ([`00_auth_shim.sql`](server/db/init/00_auth_shim.sql))
so the original `auth.uid()` / RLS policies keep working.
- **API:** [`server/src/index.js`](server/src/index.js) — Express. Issues JWTs
with `bcrypt` + `jsonwebtoken`, opens a pooled connection per request, sets
`request.jwt.claim.*` and `SET LOCAL ROLE authenticated` so RLS evaluates
against the caller. Generic `/rpc/:fn` route forwards to `app.<fn>(...)`
SQL functions; `/from/:view` exposes an allow-listed set of read views.
- **Frontend:** Vite + React 18 + TypeScript + shadcn/ui + Tailwind. The
module at [`src/integrations/supabase/client.ts`](src/integrations/supabase/client.ts)
is **a drop-in shim** over [`src/lib/api.ts`](src/lib/api.ts) — same
`.auth`, `.rpc`, `.from(...)` surface, but talks to the Express server.
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
See [CLAUDE.md](CLAUDE.md) for the full design notes and conventions.
Follow these steps:
## Run it
```sh
# Step 1: Clone the repository using the project's Git URL.
git clone <YOUR_GIT_URL>
```bash
# one-time
cp server/.env.example server/.env # set JWT_SECRET, etc.
npm install
npm --prefix server install
# Step 2: Navigate to the project directory.
cd <YOUR_PROJECT_NAME>
# day-to-day (DB + API + Web all together)
npm run dev:all
# DB → docker container crm_omt_db on :5432
# API → node server on :4000
# WEB → vite on :5173
# Step 3: Install the necessary dependencies.
npm i
# build the frontend
npm run build
# Step 4: Start the development server with auto-reloading and an instant preview.
npm run dev
# wipe & rebuild the DB (reruns migrations + reseeds the admin)
npm run db:reset
```
**Edit a file directly in GitHub**
Default seed admin (override via env in [docker-compose.yml](docker-compose.yml)):
- Navigate to the desired file(s).
- Click the "Edit" button (pencil icon) at the top right of the file view.
- Make your changes and commit the changes.
- email: `admin@local.test`
- password: `ChangeMe123!`
**Use GitHub Codespaces**
## Shop deployment quickstart
- Navigate to the main page of your repository.
- Click on the "Code" button (green button) near the top right.
- Select the "Codespaces" tab.
- Click on "New codespace" to launch a new Codespace environment.
- Edit files directly within the Codespace and commit and push your changes once you're done.
For a machine in the shop, use the full guide at [docs/SHOP_SETUP.md](docs/SHOP_SETUP.md).
## What technologies are used for this project?
Fast path:
This project is built with:
```bash
git clone https://github.com/Krikorios/OMT-SM.git
cd OMT-SM
- Vite
- TypeScript
- React
- shadcn-ui
- Tailwind CSS
cp .env.example .env
cp server/.env.example server/.env
## How can I deploy this project?
npm install
npm --prefix server install
Simply open [Lovable](https://lovable.dev/projects/a9d33dc6-ac68-4ccf-a697-7b7f5a0390bf) and click on Share -> Publish.
./scripts/start_prod.sh
```
## Can I connect a custom domain to my Lovable project?
Important:
Yes, you can!
- Set strong secrets in `.env` and `server/.env` before first production use.
- Match `POSTGRES_PASSWORD` in `.env` with the password inside `server/.env` `DATABASE_URL`.
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
## Tech stack
React 18, TypeScript, Vite, shadcn/ui, Tailwind, TanStack Query, react-hook-form,
zod · Express 4, pg, bcrypt, jsonwebtoken · PostgreSQL 16 · Docker Compose.
💼 Employee Payment Reconciliation Dashboard
A mini web application built with React and Supabase that tracks daily collection vs deposit transactions for employees and enforces a carry-forward balance logic. If an employee's deposit on a given day is less than the collection amount, the shortfall is rolled over and must be cleared by future deposits. The app processes transaction data, maintains a running balance, and generates an intuitive dashboard to visualize employee payment behavior.
Key Features:
Fetch and display employee-wise transaction data
Calculate running outstanding balances per employee
Apply deposit payments to past shortfalls based on business rules
Built using React, TypeScript, and Supabase (PostgreSQL backend)
Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide)
BIN
View File
Binary file not shown.
+28
View File
@@ -0,0 +1,28 @@
services:
db:
image: postgres:16-alpine
container_name: crm_omt_db
restart: unless-stopped
# bind to loopback only — DB is reachable from the API on this box, not from the LAN
ports:
- "127.0.0.1:5432:5432"
environment:
# all of these MUST come from the .env file at repo root — no defaults
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_DB: ${POSTGRES_DB:-crm_omt}
ADMIN_EMAIL: ${ADMIN_EMAIL:?ADMIN_EMAIL is required}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
ADMIN_NAME: ${ADMIN_NAME:-Owner}
volumes:
- dbdata:/var/lib/postgresql/data
- ./supabase/migrations:/sql/migrations:ro
- ./server/db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d crm_omt"]
interval: 5s
timeout: 5s
retries: 20
volumes:
dbdata:
+72
View File
@@ -0,0 +1,72 @@
# Shop Setup Guide (Production)
This guide is for a fresh machine at the shop.
## 1. Prerequisites
- Docker Desktop installed and running
- Node.js 20+ and npm
- Git
## 2. Pull and prepare
```bash
git clone https://github.com/Krikorios/OMT-SM.git
cd OMT-SM
npm install
npm --prefix server install
```
## 3. Configure environment
Create root env for Docker DB + seeded owner:
```bash
cp .env.example .env
```
Edit `.env` and set strong values for `POSTGRES_PASSWORD` and `ADMIN_PASSWORD`.
Create API env:
```bash
cp server/.env.example server/.env
```
Edit `server/.env`:
- Set `DATABASE_URL` password to match root `.env` `POSTGRES_PASSWORD`
- Set a strong `JWT_SECRET` (32+ chars)
- Keep `NODE_ENV=production`
## 4. Start in production mode
```bash
./scripts/start_prod.sh
```
The app is then reachable on:
- http://localhost:4000
- http://127.0.0.1:4000
## 5. Optional operations
Reset DB and rerun all migrations + seed:
```bash
npm run db:reset
```
Run nightly backups (cron example is inside the script header):
```bash
./scripts/backup.sh
```
## 6. First login
Use the admin email/password from root `.env`:
- `ADMIN_EMAIL`
- `ADMIN_PASSWORD`
Then create cashier/manager users from the UI.
+78
View File
@@ -0,0 +1,78 @@
# Threat Model — OMT / Recharge Cell Shop
> **Purpose.** Document, before any code is written, every realistic way an
> employee (or a colluding pair) can steal money or goods from the shop, and
> the specific control the system must enforce to block each path. Every later
> migration, RLS policy, trigger, and UI rule must trace back to a row in this
> table.
>
> This file is **append-only** in spirit: when a new theft vector is
> discovered, add a row; do not delete history.
## Actors
| Actor | Description |
| ---------- | -------------------------------------------------------------- |
| `cashier` | Operates a till during a shift. Highest fraud-risk role. |
| `manager` | Approves voids, refunds, overrides. Can collude with cashier. |
| `owner` | Read-everything. Surprise inspections. Sets prices/fees. |
| `auditor` | Read-only third party (accountant). |
| `customer` | May be an accomplice (fake refund, fake cancellation). |
## Trust boundaries
1. **Browser/POS ↔ Supabase**: client is hostile. Never trust client-supplied
prices, fees, FX rates, timestamps, user IDs, or shift IDs.
2. **App role ↔ Postgres**: even the service role must not be able to `DELETE`
from the ledger or rewrite hashes. Enforce with table grants + triggers.
3. **Shop ↔ Provider (OMT / Alfa / touch / Bank)**: the provider's statement
is the source of truth for reconciliation. Any local row not present on the
provider statement is suspect.
## Theft vectors and required controls
| # | Vector | Control (must exist before go-live) | Enforced in |
| -- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| 1 | Pocket cash, never enter the transaction | Mandatory printed receipt, sequential `reference_no` per shop, customer SMS with reference, gap-detection report | DB + server |
| 2 | Enter transaction, then edit / delete it after customer leaves | Append-only ledger; `UPDATE`/`DELETE` revoked; void requires reason + manager PIN; row hash chain | DB triggers |
| 3 | Recharge own/friend's number flagged as "test" | No "test" flag exists; every recharge debits stock or e-float | Schema |
| 4 | Sell a scratch card, keep cash, claim "lost card" | Voucher serial scanned on intake **and** on sale; loss requires manager approval; variance assigned to cashier | DB + UI |
| 5 | Use shop OMT terminal to send to themselves at zero / reduced fee | Fee schedule server-side and immutable per cashier; OMT statement reconciliation; alert on cashier as sender or beneficiary | DB + recon job |
| 6 | Manipulate FX rate (e.g. "I gave 89,000 LBP/USD instead of 90,000") | `fx_rates` populated by scheduled job; cashier selects, never types; rate stamped on each txn | DB + cron |
| 7 | Skim cash from drawer, blame "shortfall" | Blind closing count (declared before expected revealed); per-cashier variance trend; chronic shortage alert | UI + report |
| 8 | Collect customer cash, "deposit later", never deposit | Forced shift close before leaving; aging-outstanding alert > N hours; bank-deposit reconciliation | DB + recon |
| 9 | Share login with a colleague | Per-user PIN re-prompt, device binding, session timeout, no shared accounts | Auth |
| 10 | Print fake receipts on a second printer | Receipt carries server-signed QR (JWT of `txn_id`); spot-scan by owner returns DB row or "FAKE" | Server |
| 11 | Refund/void to themselves | Void/refund requires manager PIN on same device; cashier cannot self-approve; daily void report per cashier | DB + UI |
| 12 | Sell recharge below price ("friend discount") | Price list server-controlled; cashier UI has no price field; override only by manager and logged | DB + UI |
| 13 | Take goods (phones/accessories) without sale | Per-shift inventory count; CCTV ↔ txn time sync; variance report | UI + ops |
| 14 | Tamper with the closing count | Declared count entered first and locked; expected revealed only after; both stored | DB |
| 15 | Accomplice customer "cancels" after cash handed | Cancellations require manager + reason + photo of voided receipt; CCTV cross-check | UI + ops |
| 16 | Structure large transfers under multiple fake walk-in identities | KYC threshold per (customer, day) and (beneficiary, week); customer record mandatory ≥ threshold | DB + AML report |
| 17 | Off-hours transaction when nobody is watching | Shift hours per shop; after-hours flag + alert | DB + alert |
| 18 | Managercashier collusion to mass-void real sales | Void rate per (cashier, manager) pair trended; owner-only weekly review; voids count against shift variance | Report |
| 19 | Replay an old OMT receipt to a new customer | `external_ref` unique per provider; duplicate detection on insert | DB constraint |
| 20 | Cashier opens a second, undeclared till on the same device | One open shift per `till_id`; device fingerprint pinned to till | DB + auth |
| 21 | Cashier marks recharge "failed at provider" and keeps cash | Provider e-recharge response stored as `external_ref` and reconciled; "failed" requires provider failure id | DB + recon |
| 22 | Cashier "exchanges currency" at a worse rate than recorded, pocketing the spread | FX swap is a typed `cash_movement` with both legs at the system rate; deviation requires manager override | DB |
| 23 | Cashier deletes their browser data to "lose" pending offline transactions | Offline queue persisted with server-issued idempotency key; missing key sequences flagged on reconnect | Client + server |
| 24 | Insider (developer/DBA) silently edits the database | Hash chain on ledger; daily hash anchor exported off-site; restricted DB roles; audit of all DDL and privileged SQL | DB + ops |
| 25 | Backdated transaction to fit a doctored shift count | `occurred_at` server-side `now()`; cashier cannot set; backdate only by owner role with reason | DB |
## Non-negotiables (no go-live without these)
1. RLS on every table; no table is publicly readable or writable.
2. `INSERT`-only ledger with row hash chain; `DELETE` revoked from every role.
3. All money writes go through `SECURITY DEFINER` Postgres functions, not raw
table writes.
4. Server-controlled prices, fees, and FX rates. No cashier-typed money rules.
5. Blind cash close per shift, with declared-vs-expected variance stored.
6. External reconciliation (OMT, Alfa, touch, bank) before any month-close.
7. Per-user account, device-bound, with PIN re-prompt for sensitive ops.
8. Receipt with signed QR linking back to the ledger row.
## Review cadence
- Every new feature PR must reference at least one row above (or add one).
- Quarterly walk-through: pick 5 random rows, demonstrate the control still
works on a staging environment.
+258 -244
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -5,6 +5,13 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:all": "concurrently -k -n DB,API,WEB -c blue,magenta,green \"npm run db:up && docker logs -f crm_omt_db\" \"npm run server:dev\" \"vite\"",
"db:up": "docker compose up -d db",
"db:down": "docker compose down",
"db:reset": "docker compose down -v && docker compose up -d db",
"server:install": "npm --prefix server install",
"server:dev": "npm --prefix server run dev",
"server:start": "npm --prefix server start",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
@@ -39,7 +46,6 @@
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@supabase/supabase-js": "^2.49.8",
"@tanstack/react-query": "^5.56.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -70,6 +76,7 @@
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Nightly backup of the production DB.
# Add to crontab: 5 2 * * * /path/to/cash-collection-management-system/scripts/backup.sh >> /var/log/crm_omt_backup.log 2>&1
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKUP_DIR="${BACKUP_DIR:-$REPO_DIR/backups}"
KEEP_DAYS="${KEEP_DAYS:-30}"
TS="$(date +%Y%m%d_%H%M%S)"
OUT="$BACKUP_DIR/crm_omt_${TS}.sql.gz"
mkdir -p "$BACKUP_DIR"
docker exec crm_omt_db pg_dump -U postgres -d crm_omt --clean --if-exists \
| gzip -9 > "$OUT"
# Prune anything older than KEEP_DAYS days
find "$BACKUP_DIR" -name 'crm_omt_*.sql.gz' -type f -mtime "+$KEEP_DAYS" -delete
echo "[backup] wrote $OUT ($(du -h "$OUT" | cut -f1))"
+90
View File
@@ -0,0 +1,90 @@
-- =====================================================================
-- DB invariants: run against a closed E2E shift to verify SQL-side rules.
-- Pass shift id via: psql ... -v shift_id="'<uuid>'"
-- =====================================================================
\set ON_ERROR_STOP on
\timing off
\echo
\echo === shift under test ===
select id, status, opening_usd, expected_close_usd, declared_close_usd,
variance_usd, opening_lbp, expected_close_lbp, variance_lbp
from app.shifts where id = :shift_id;
\echo
\echo === INV1: every completed REPAIR txn has >=1 cash_movements row ===
select t.id, t.service_code, t.status
from app.transactions t
where t.shift_id = :shift_id
and t.status = 'completed'
and t.service_code = 'REPAIR'
and not exists (select 1 from app.cash_movements cm where cm.ref_txn_id = t.id);
\echo (expect 0 rows)
\echo
\echo === INV2: voided txns have net-zero cash (per currency) ===
with v as (
select id from app.transactions
where shift_id = :shift_id and status = 'voided'
)
select cm.ref_txn_id, cm.currency, sum(cm.amount) as net
from app.cash_movements cm
join v on v.id = cm.ref_txn_id
group by cm.ref_txn_id, cm.currency
having sum(cm.amount) <> 0;
\echo (expect 0 rows)
\echo
\echo === INV3: opening + Σ cash_movements(post-open) = expected_close ===
with s as (select * from app.shifts where id = :shift_id),
mv_usd as (
select coalesce(sum(amount),0) as total
from app.cash_movements
where shift_id = :shift_id and currency = 'USD' and type <> 'opening_float'
),
mv_lbp as (
select coalesce(sum(amount),0) as total
from app.cash_movements
where shift_id = :shift_id and currency = 'LBP' and type <> 'opening_float'
)
select s.opening_usd, mv_usd.total as movements_usd,
(s.opening_usd + mv_usd.total) as computed_usd,
s.expected_close_usd,
(s.opening_usd + mv_usd.total = s.expected_close_usd) as usd_ok,
s.opening_lbp, mv_lbp.total as movements_lbp,
(s.opening_lbp + mv_lbp.total) as computed_lbp,
s.expected_close_lbp,
(s.opening_lbp + mv_lbp.total = s.expected_close_lbp) as lbp_ok
from s, mv_usd, mv_lbp;
\echo (expect usd_ok = t AND lbp_ok = t)
\echo
\echo === INV4: variance = declared - expected ===
select id,
(declared_close_usd - expected_close_usd) as computed_var_usd,
variance_usd,
(declared_close_usd - expected_close_usd) = variance_usd as usd_ok,
(declared_close_lbp - expected_close_lbp) as computed_var_lbp,
variance_lbp,
(declared_close_lbp - expected_close_lbp) = variance_lbp as lbp_ok
from app.shifts where id = :shift_id;
\echo (expect usd_ok = t AND lbp_ok = t)
\echo
\echo === INV5: cash_movements sign rule never violated (whole DB) ===
select id, shift_id, type, currency, amount
from app.cash_movements
where (type in ('sale_in','fx_swap_in','opening_float') and amount <= 0)
or (type in ('payout_out','drop_to_safe','bank_deposit','expense','fx_swap_out') and amount >= 0);
\echo (expect 0 rows)
\echo
\echo === INV6: cash_movements.ref_txn_id always resolves ===
select cm.id, cm.ref_txn_id
from app.cash_movements cm
where cm.ref_txn_id is not null
and not exists (select 1 from app.transactions t where t.id = cm.ref_txn_id);
\echo (expect 0 rows)
\echo
\echo === DONE ===
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
// End-to-end production-readiness validation against the running stack.
// Usage: node scripts/e2e_validate.mjs
// ENV: API=http://localhost:4000 EMAIL=admin@local.test PASSWORD=ChangeMe123!
const API = process.env.API || 'http://localhost:4000';
const EMAIL = process.env.EMAIL || 'admin@local.test';
const PASSWORD = process.env.PASSWORD || 'ChangeMe123!';
let TOKEN = null;
let failures = 0;
let checks = 0;
const fmt = (n) => Number(n).toFixed(2);
const eq = (a, b, tol = 0.0001) => Math.abs(Number(a) - Number(b)) <= tol;
function pass(msg) { checks++; console.log(` PASS ${msg}`); }
function fail(msg, extra='') { checks++; failures++; console.log(` FAIL ${msg}${extra ? ' ('+extra+')' : ''}`); }
function step(msg) { console.log(`\n== ${msg}`); }
async function req(method, path, body) {
const r = await fetch(`${API}${path}`, {
method,
headers: {
'content-type': 'application/json',
...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await r.text();
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
if (!r.ok) {
const err = new Error(`${method} ${path} -> ${r.status} ${JSON.stringify(json)}`);
err.body = json;
throw err;
}
return json;
}
const rpc = (fn, args) => req('POST', `/rpc/${fn}`, args).then(r => r.data);
const view = (v) => req('GET', `/from/${v}`).then(r => r.data);
(async () => {
step('login');
const login = await req('POST', '/auth/login', { email: EMAIL, password: PASSWORD });
TOKEN = login.token;
const ADMIN_ID = login.user.id;
pass(`login as ${login.user.email}`);
step('discover shop + till');
const shops = await view('v_my_shops');
if (!shops.length) { fail('no shops visible'); process.exit(1); }
const shopId = shops[0].shop_id;
pass(`shop ${shops[0].name} (${shopId})`);
const tillName = `E2E-Till-${Date.now()}`;
const newTill = await rpc('create_till', { p_shop: shopId, p_name: tillName });
const tillId = typeof newTill === 'string' ? newTill : newTill?.id ?? newTill;
pass(`created till ${tillName} (${tillId})`);
step('open shift with opening float USD 100 / LBP 0 (owner assigns to self)');
const openingUsd = 100, openingLbp = 0;
await rpc('open_shift', {
p_till_id: tillId,
p_opening_usd: openingUsd,
p_opening_lbp: openingLbp,
p_assigned_user_id: ADMIN_ID,
});
const active = await rpc('my_active_shift', { p_shop: shopId });
const shift = Array.isArray(active) ? active[0] : active;
if (!shift?.shift_id) { fail('shift did not open'); process.exit(1); }
const shiftId = shift.shift_id;
pass(`shift opened ${shiftId} status=${shift.status}`);
step('record REPAIR USD 30 (cash-only path)');
const repair1 = await rpc('record_repair', {
p_shop: shopId, p_till: tillId,
p_payment_method: 'cash_usd',
p_gross_usd: 30, p_gross_lbp: 0,
p_fx_rate: null,
p_device_type: 'iPhone 12',
p_device_imei: null,
p_issue_summary: 'screen replacement',
p_warranty_days: 7,
p_customer_id: null,
p_notes: 'e2e r1',
});
pass(`REPAIR #1 txn ${repair1}`);
step('record REPAIR USD 20');
const repair2 = await rpc('record_repair', {
p_shop: shopId, p_till: tillId,
p_payment_method: 'cash_usd',
p_gross_usd: 20, p_gross_lbp: 0,
p_fx_rate: null,
p_device_type: 'Samsung A50',
p_device_imei: null,
p_issue_summary: 'battery replacement',
p_warranty_days: 30,
p_customer_id: null,
p_notes: 'e2e r2',
});
pass(`REPAIR #2 txn ${repair2}`);
step('midday safe drop USD 25');
await rpc('record_cash_drop', {
p_shift_id: shiftId,
p_drop_usd: 25,
p_drop_lbp: 0,
p_notes: 'e2e midday',
});
pass('drop recorded');
step('ENFORCEMENT: void without voided_paper_photo evidence must be REJECTED');
let voidRejected = false;
try {
await rpc('void_transaction', {
p_txn_id: repair1,
p_reason: 'e2e void without evidence',
});
} catch (e) {
voidRejected = true;
const msg = e.body?.error || e.message;
if (/voided_paper_photo/i.test(msg)) {
pass(`void correctly rejected: ${msg}`);
} else {
fail(`void rejected but for unexpected reason: ${msg}`);
}
}
if (!voidRejected) fail('SECURITY: void with no evidence was accepted!');
step('ENFORCEMENT: cash_movements sign guard (drop with negative amount should fail)');
let dropGuardOk = false;
try {
await rpc('record_cash_drop', {
p_shift_id: shiftId,
p_drop_usd: -10,
p_drop_lbp: 0,
p_notes: 'e2e negative drop',
});
} catch (e) {
dropGuardOk = true;
pass(`negative drop correctly rejected: ${e.body?.error || e.message}`);
}
if (!dropGuardOk) fail('SECURITY: negative drop amount was accepted!');
// Expected drawer math (USD, no void applied):
// opening 100
// + REPAIR1 +30
// + REPAIR2 +20
// - drop -25
// = 125
const expectedUsd = 125;
const expectedLbp = 0;
const counted = 124; // intentional $1 short
step('declare close USD 124 (intentional $1 short)');
await rpc('declare_close', {
p_shift_id: shiftId,
p_declared_close_usd: counted,
p_declared_close_lbp: 0,
});
pass('declared');
step('finalize close + assert expected/variance');
const fin = await rpc('finalize_close', { p_shift_id: shiftId });
const row = Array.isArray(fin) ? fin[0] : fin;
console.log(' finalize_close ->', JSON.stringify(row));
eq(row.expected_usd, expectedUsd)
? pass(`expected_usd = ${fmt(row.expected_usd)} (== ${expectedUsd})`)
: fail(`expected_usd = ${fmt(row.expected_usd)} (!= ${expectedUsd})`);
eq(row.expected_lbp, expectedLbp)
? pass(`expected_lbp = ${fmt(row.expected_lbp)} (== ${expectedLbp})`)
: fail(`expected_lbp = ${fmt(row.expected_lbp)} (!= ${expectedLbp})`);
eq(row.variance_usd, counted - expectedUsd)
? pass(`variance_usd = ${fmt(row.variance_usd)} (== ${counted - expectedUsd})`)
: fail(`variance_usd = ${fmt(row.variance_usd)} (expected ${counted - expectedUsd})`);
// ------------------------------------------------------------------
// DB-level invariant checks via /rpc/<admin diagnostic>... we don't
// have such an RPC, so just print a SQL block for the runner to exec.
// ------------------------------------------------------------------
step('SUMMARY');
console.log(` checks: ${checks} failures: ${failures}`);
if (failures) process.exit(2);
console.log(`\nShift under test: ${shiftId}`);
})().catch(e => {
console.error('FATAL', e.message);
if (e.body) console.error(JSON.stringify(e.body, null, 2));
process.exit(1);
});
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Start the full production stack on this box.
set -euo pipefail
cd "$(dirname "$0")/.."
# 1. DB up (idempotent)
docker compose up -d db
# 2. Wait for DB to be healthy
for i in $(seq 1 30); do
if docker exec crm_omt_db pg_isready -U postgres -d crm_omt >/dev/null 2>&1; then
break
fi
sleep 1
done
# 3. Build frontend (no-op if dist/ already current; safe to re-run)
if [ ! -d dist ] || [ -n "$(find src -newer dist -type f -print -quit 2>/dev/null)" ]; then
echo "[prod] building frontend..."
npm run build
fi
# 4. Start API in production mode (foreground; use a process manager for restart)
cd server
node src/index.js
+12
View File
@@ -0,0 +1,12 @@
# Production API config (copy to server/.env)
NODE_ENV=production
PORT=4000
BIND_HOST=127.0.0.1
DATABASE_URL=postgres://postgres:replace-with-db-password@localhost:5432/crm_omt
JWT_SECRET=replace-with-a-long-random-string-at-least-32-chars
JWT_EXPIRES_IN=12h
# Same-origin when SPA is served by this API.
# Keep localhost/127.0.0.1 unless you intentionally expose the service behind a reverse proxy.
CORS_ORIGIN=http://localhost:4000,http://127.0.0.1:4000
ENABLE_LOCAL_TEST_ROUTES=0
+64
View File
@@ -0,0 +1,64 @@
-- =====================================================================
-- 0000 Auth shim
-- Provides `auth.users`, `auth.uid()`, `auth.role()`, `auth.jwt()` so
-- the application migrations (which were written for Supabase) run
-- unmodified. The backend sets `request.jwt.claim.sub` (and friends)
-- per request from the verified JWT, then `set local role authenticated`.
-- =====================================================================
create extension if not exists "pgcrypto";
create extension if not exists "citext";
-- Supabase ships these roles; create them if missing (e.g. plain Postgres).
do $$ begin
if not exists (select 1 from pg_roles where rolname = 'anon') then
create role anon nologin noinherit;
end if;
if not exists (select 1 from pg_roles where rolname = 'authenticated') then
create role authenticated nologin noinherit;
end if;
if not exists (select 1 from pg_roles where rolname = 'service_role') then
create role service_role nologin noinherit bypassrls;
end if;
end $$;
create schema if not exists auth;
-- Minimal `auth.users` compatible with FKs in app migrations.
create table if not exists auth.users (
id uuid primary key default gen_random_uuid(),
email citext unique,
password_hash text not null,
full_name text,
is_system_admin boolean not null default false,
is_active boolean not null default true,
created_at timestamptz not null default now(),
last_login_at timestamptz
);
create or replace function auth.uid()
returns uuid
language sql
stable
as $$
select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid
$$;
create or replace function auth.role()
returns text
language sql
stable
as $$
select coalesce(nullif(current_setting('request.jwt.claim.role', true), ''), 'anon')
$$;
create or replace function auth.jwt()
returns jsonb
language sql
stable
as $$
select coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb)
$$;
grant usage on schema auth to authenticated, anon, service_role;
grant select on auth.users to authenticated, service_role;
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Run all SQL migrations from /sql/migrations/ in lexical order.
# On plain Postgres (no pg_cron extension), wrap any bare
# `create extension if not exists pg_cron;` line so it doesn't abort.
set -euo pipefail
TMPDIR_M=/tmp/migrations
mkdir -p "$TMPDIR_M"
echo ">> applying app migrations from /sql/migrations"
for f in /sql/migrations/*.sql; do
base="$(basename "$f")"
# Replace the bare pg_cron extension creation with a soft variant.
sed -E "s|^create extension if not exists pg_cron;|do \$\$ begin create extension if not exists pg_cron; exception when others then raise notice 'pg_cron unavailable, skipping schedules'; end \$\$;|i" "$f" > "$TMPDIR_M/$base"
echo ">> $base"
psql -v ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" \
--dbname "$POSTGRES_DB" \
-f "$TMPDIR_M/$base"
done
echo ">> migrations complete"
+116
View File
@@ -0,0 +1,116 @@
-- =====================================================================
-- Local extension migration: simple employee payment ledger used by the
-- Employee Payment Report UI. Backed by the API; not a Supabase migration.
-- =====================================================================
create table if not exists app.employees (
id uuid primary key default gen_random_uuid(),
emp_id text not null unique,
name text not null,
email text,
department text,
location text,
created_at timestamptz not null default now()
);
create table if not exists app.employee_transactions (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references app.employees(id) on delete cascade,
transaction_date date not null,
collection_amount numeric(18,2) not null default 0,
deposit_amount numeric(18,2) not null default 0,
currency text not null check (currency in ('USD','LBP')),
created_at timestamptz not null default now()
);
create index if not exists idx_emp_tx_emp on app.employee_transactions(employee_id, transaction_date desc);
-- These tables are owned by the API; RLS off, gated at the HTTP layer.
alter table app.employees disable row level security;
alter table app.employee_transactions disable row level security;
grant select, insert, update, delete on app.employees to authenticated;
grant select, insert, update, delete on app.employee_transactions to authenticated;
-- Report-ready bridge from the modern POS/shift ledger into the legacy
-- employee payment report shape. A negative closed-shift variance means the
-- cashier is short, so it increases outstanding collection. A positive
-- variance means the drawer is over, so it is treated as a deposit/credit.
create or replace view app.v_employee_outstanding_balances as
with manual as (
select
e.id as employee_id,
e.emp_id,
e.name,
e.email,
e.department,
e.location,
et.currency,
sum(et.collection_amount) as manual_collection,
sum(et.deposit_amount) as manual_deposit,
0::numeric as shift_shortage,
0::numeric as shift_overage,
max(et.transaction_date)::timestamptz as last_activity_at
from app.employees e
join app.employee_transactions et on et.employee_id = e.id
group by e.id, e.emp_id, e.name, e.email, e.department, e.location, et.currency
), shift_variance as (
select
e.id as employee_id,
coalesce(e.emp_id, 'AUTH-' || left(u.id::text, 8)) as emp_id,
coalesce(e.name, p.full_name, u.full_name, u.email) as name,
u.email,
e.department,
e.location,
currency_rows.currency,
0::numeric as manual_collection,
0::numeric as manual_deposit,
sum(greatest(-currency_rows.variance_amount, 0)) as shift_shortage,
sum(greatest(currency_rows.variance_amount, 0)) as shift_overage,
max(sh.closed_at) as last_activity_at
from app.shifts sh
join auth.users u on u.id = sh.user_id
left join app.user_profiles p on p.user_id = u.id
left join app.employees e on lower(e.email) = lower(u.email)
cross join lateral (values
('USD'::text, coalesce(sh.variance_usd, 0)::numeric),
('LBP'::text, coalesce(sh.variance_lbp, 0)::numeric)
) as currency_rows(currency, variance_amount)
where sh.status = 'closed'
and currency_rows.variance_amount <> 0
group by e.id, coalesce(e.emp_id, 'AUTH-' || left(u.id::text, 8)),
coalesce(e.name, p.full_name, u.full_name, u.email), u.email,
e.department, e.location, currency_rows.currency
), combined as (
select * from manual
union all
select * from shift_variance
)
select
coalesce(
employee_id,
(
substr(md5(coalesce(email, emp_id)), 1, 8) || '-' ||
substr(md5(coalesce(email, emp_id)), 9, 4) || '-' ||
substr(md5(coalesce(email, emp_id)), 13, 4) || '-' ||
substr(md5(coalesce(email, emp_id)), 17, 4) || '-' ||
substr(md5(coalesce(email, emp_id)), 21, 12)
)::uuid
) as employee_id,
emp_id,
name,
email,
department,
location,
currency,
sum(manual_collection) as manual_collection,
sum(manual_deposit) as manual_deposit,
sum(shift_shortage) as shift_shortage,
sum(shift_overage) as shift_overage,
sum(manual_collection + shift_shortage) as total_collection,
sum(manual_deposit + shift_overage) as total_deposit,
sum(manual_collection + shift_shortage - manual_deposit - shift_overage) as outstanding_amount,
max(last_activity_at) as last_activity_at
from combined
group by employee_id, emp_id, name, email, department, location, currency;
grant select on app.v_employee_outstanding_balances to authenticated;
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Seed (or reset) the default admin user, default shop, owner role, Till 1.
set -euo pipefail
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@local.test}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-ChangeMe123!}"
ADMIN_NAME="${ADMIN_NAME:-Local Admin}"
echo ">> seeding admin user: ${ADMIN_EMAIL}"
# Use psql -v to safely substitute values inside the DO block via :'name' --
# but :'name' only works at top level. So we generate plain SQL with the
# values inlined as quoted literals (escaping single quotes).
escape() { printf "%s" "$1" | sed "s/'/''/g"; }
EM=$(escape "$ADMIN_EMAIL")
PW=$(escape "$ADMIN_PASSWORD")
NM=$(escape "$ADMIN_NAME")
psql -v ON_ERROR_STOP=1 \
--username "$POSTGRES_USER" \
--dbname "$POSTGRES_DB" <<SQL
do \$\$
declare
v_user_id uuid;
v_shop_id uuid;
begin
insert into auth.users(email, password_hash, full_name, is_system_admin, is_active)
values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true, true)
on conflict (email) do update
set password_hash = excluded.password_hash,
full_name = excluded.full_name,
is_system_admin = true,
is_active = true
returning id into v_user_id;
insert into app.user_profiles(user_id, full_name, is_active)
values (v_user_id, '${NM}', true)
on conflict (user_id) do update
set full_name = excluded.full_name,
is_active = true;
insert into app.shops(name, created_by)
values ('Default Shop', v_user_id)
on conflict do nothing;
select id into v_shop_id from app.shops where name = 'Default Shop' limit 1;
insert into app.user_shop_assignments(user_id, shop_id, role, assigned_by)
values (v_user_id, v_shop_id, 'owner', v_user_id)
on conflict (user_id, shop_id) do update set role = 'owner';
insert into app.tills(shop_id, name)
values (v_shop_id, 'Till 1')
on conflict (shop_id, name) do nothing;
end \$\$;
SQL
echo ">> admin user ensured: ${ADMIN_EMAIL}"
+1752
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "crm-omt-server",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.1"
}
}
+506
View File
@@ -0,0 +1,506 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import pkg from 'pg';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
const { Pool } = pkg;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const IS_PROD = process.env.NODE_ENV === 'production';
const BIND_HOST = process.env.BIND_HOST || (IS_PROD ? '127.0.0.1' : '0.0.0.0');
const PORT = Number(process.env.PORT || 4000);
const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/crm_omt';
if (IS_PROD) {
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
console.error('[server] refusing to start: JWT_SECRET missing or too short in production');
process.exit(1);
}
if (/:(postgres|password|change[-_]?me)@/i.test(DATABASE_URL)) {
console.error('[server] refusing to start: DATABASE_URL still uses a default password');
process.exit(1);
}
}
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '12h';
const LOCAL_DEV_TOOLS_ENABLED = !IS_PROD
&& (process.env.ENABLE_LOCAL_TEST_ROUTES === '1'
|| /localhost|127\.0\.0\.1/i.test(DATABASE_URL));
const CORS_ORIGINS = (process.env.CORS_ORIGIN || (IS_PROD ? '' : 'http://localhost:5173,http://localhost:8080'))
.split(',').map(s => s.trim()).filter(Boolean);
// Always allow same-origin requests when we're serving the SPA from this server.
const SAME_ORIGIN_HOSTS = ['localhost', '127.0.0.1'];
const STATIC_DIR = process.env.STATIC_DIR
|| path.resolve(__dirname, '..', '..', 'dist');
const SERVE_STATIC = IS_PROD && fs.existsSync(STATIC_DIR);
function isPrivateIpv4(hostname) {
return /^10\./.test(hostname)
|| /^127\./.test(hostname)
|| /^192\.168\./.test(hostname)
|| /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname);
}
function isAllowedOrigin(origin) {
if (!origin) return true;
if (CORS_ORIGINS.includes(origin)) return true;
// Same-origin: when the SPA is served from this server, the browser sends
// Origin: http://<host>:<PORT>. Allow that pair regardless of NODE_ENV.
try {
const url = new URL(origin);
if (SAME_ORIGIN_HOSTS.includes(url.hostname) && Number(url.port || (url.protocol === 'https:' ? 443 : 80)) === PORT) {
return true;
}
if (IS_PROD) return false; // in prod, only explicit list + same-origin
return SAME_ORIGIN_HOSTS.includes(url.hostname) || isPrivateIpv4(url.hostname);
} catch {
return false;
}
}
const pool = new Pool({ connectionString: DATABASE_URL, max: 10 });
const app = express();
// Same-origin requests (where the browser's Origin host:port matches the
// request's own Host header) are always allowed. This makes the API + SPA
// combo work whether the cashier opens http://localhost:4000,
// http://127.0.0.1:4000, or http://thispc.local:4000.
function isSameOrigin(req) {
const origin = req.get('origin');
if (!origin) return true;
try {
const o = new URL(origin);
const host = req.get('host') || '';
return `${o.hostname}:${o.port || (o.protocol === 'https:' ? '443' : '80')}` === host
|| o.host === host;
} catch {
return false;
}
}
app.use(cors({
origin(origin, callback) {
// Pass-through; per-request same-origin check happens below.
if (!origin) return callback(null, true);
if (isAllowedOrigin(origin)) return callback(null, true);
return callback(null, false);
},
credentials: true,
}));
// Belt + suspenders: if the cors() middleware rejected based on a stale
// allowlist but the request is actually same-origin, let it through.
app.use((req, res, next) => {
if (isSameOrigin(req)) {
const origin = req.get('origin');
if (origin && !res.get('Access-Control-Allow-Origin')) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
}
}
next();
});
app.use(express.json({ limit: '1mb' }));
// ---- helpers ---------------------------------------------------------
function signToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, role: 'authenticated' },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN, audience: 'authenticated' },
);
}
function authRequired(req, res, next) {
const hdr = req.get('authorization') || '';
const m = hdr.match(/^Bearer\s+(.+)$/i);
if (!m) return res.status(401).json({ error: 'missing token' });
try {
const claims = jwt.verify(m[1], JWT_SECRET);
req.user = { id: claims.sub, email: claims.email, role: claims.role || 'authenticated', claims };
next();
} catch (e) {
return res.status(401).json({ error: 'invalid token' });
}
}
/**
* Run `fn(client)` on a checked-out connection that has its session
* configured to impersonate the authenticated user, so RLS works:
* SET LOCAL request.jwt.claim.sub = <user_id>
* SET LOCAL request.jwt.claims = <full claims as json>
* SET LOCAL ROLE authenticated
*/
async function withUserClient(req, fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SELECT set_config('request.jwt.claim.sub', $1, true)", [req.user.id]);
await client.query("SELECT set_config('request.jwt.claim.role', $1, true)", [req.user.role]);
await client.query("SELECT set_config('request.jwt.claims', $1, true)", [JSON.stringify(req.user.claims)]);
await client.query("SET LOCAL ROLE authenticated");
const out = await fn(client);
await client.query('COMMIT');
return out;
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
throw e;
} finally {
client.release();
}
}
function dbError(res, e) {
console.error('[db]', e.code || '', e.message);
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
}
// ---- auth ------------------------------------------------------------
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'email and password required' });
try {
const { rows } = await pool.query(
`SELECT id, email, password_hash, full_name, is_active,
coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS is_system_admin
FROM auth.users u WHERE email = $1 LIMIT 1`,
[String(email).trim().toLowerCase()],
);
const u = rows[0];
if (!u || !u.is_active) return res.status(401).json({ error: 'invalid credentials' });
const ok = await bcrypt.compare(password, u.password_hash);
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
await pool.query('UPDATE auth.users SET last_login_at = now() WHERE id = $1', [u.id]);
const token = signToken(u);
res.json({ token, user: { id: u.id, email: u.email, full_name: u.full_name, is_system_admin: u.is_system_admin } });
} catch (e) { dbError(res, e); }
});
app.post('/auth/logout', authRequired, (_req, res) => {
// Stateless JWT — client just drops the token. (Add a denylist if needed.)
res.json({ ok: true });
});
// ---- generic RPC -----------------------------------------------------
// POST /rpc/:fn body = { ...named args matching app.<fn>(...) signature }
app.post('/rpc/:fn', authRequired, async (req, res) => {
const fn = req.params.fn;
if (!/^[a-z_][a-z0-9_]{0,62}$/i.test(fn)) {
return res.status(400).json({ error: 'invalid function name' });
}
const args = req.body && typeof req.body === 'object' ? req.body : {};
const names = Object.keys(args);
// Always call as `SELECT * FROM app.<fn>(...)` so SETOF/TABLE/composite
// functions expand to rows/columns. Scalar functions yield one row with a
// single column named after the function.
const argList = names.map((n, i) => `${n} => $${i + 1}`).join(', ');
const sql = `SELECT * FROM app.${fn}(${argList})`;
const params = names.map((n) => args[n]);
try {
const data = await withUserClient(req, async (client) => {
const r = await client.query(sql, params);
// Scalar: 1 row, 1 column => unwrap.
if (r.rows.length === 1 && r.fields.length === 1) {
return r.rows[0][r.fields[0].name];
}
// Single-row composite (e.g. RETURNS record / OUT params): return as object.
if (r.rows.length === 1) return r.rows[0];
return r.rows;
});
res.json({ data });
} catch (e) { dbError(res, e); }
});
// ---- table/view reads ------------------------------------------------
// GET /from/:view?col=val&col2=val2 -> SELECT * FROM app.<view> WHERE ...
const ALLOWED_VIEWS = new Set([
'v_my_tills', 'v_my_shops', 'v_services_active', 'v_my_recent_transactions',
'v_manage_tills', 'v_service_ui_settings',
'v_employee_outstanding_balances',
// Owner / manager dashboards (RLS still restricts rows to allowed shops):
'v_owner_dashboard', 'v_z_report', 'v_employee_scorecard_30d', 'alerts', 'v_end_of_day_reports',
]);
app.get('/from/:view', authRequired, async (req, res) => {
const view = req.params.view;
if (!ALLOWED_VIEWS.has(view)) return res.status(404).json({ error: 'unknown view' });
const filters = Object.entries(req.query).filter(([k]) => /^[a-z_][a-z0-9_]*$/i.test(k));
const where = filters.length
? 'WHERE ' + filters.map(([k], i) => `${k} = $${i + 1}`).join(' AND ')
: '';
const params = filters.map(([, v]) => v);
try {
const data = await withUserClient(req, async (client) => {
const r = await client.query(`SELECT * FROM app.${view} ${where}`, params);
return r.rows;
});
res.json({ data });
} catch (e) { dbError(res, e); }
});
// ---- employees + employee transactions (Employee Payment Report) -----
app.get('/employees', authRequired, async (_req, res) => {
try {
const { rows } = await pool.query(
'SELECT id, emp_id, name, email, department, location FROM app.employees ORDER BY name',
);
res.json({ data: rows });
} catch (e) { dbError(res, e); }
});
app.post('/employees', authRequired, async (req, res) => {
const { emp_id, name, email, department, location } = req.body || {};
if (!emp_id || !name) return res.status(400).json({ error: 'emp_id and name required' });
try {
const { rows } = await pool.query(
`INSERT INTO app.employees(emp_id, name, email, department, location)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (emp_id) DO UPDATE
SET name = EXCLUDED.name, email = EXCLUDED.email,
department = EXCLUDED.department, location = EXCLUDED.location
RETURNING id, emp_id, name, email, department, location`,
[emp_id, name, email || null, department || null, location || null],
);
res.json({ data: rows[0] });
} catch (e) { dbError(res, e); }
});
app.get('/employee_transactions', authRequired, async (req, res) => {
const { employee_id } = req.query;
try {
const { rows } = await pool.query(
employee_id
? `SELECT id, employee_id, transaction_date, collection_amount, deposit_amount, currency
FROM app.employee_transactions WHERE employee_id = $1 ORDER BY transaction_date`
: `SELECT id, employee_id, transaction_date, collection_amount, deposit_amount, currency
FROM app.employee_transactions ORDER BY transaction_date`,
employee_id ? [employee_id] : [],
);
res.json({ data: rows });
} catch (e) { dbError(res, e); }
});
app.post('/employee_transactions', authRequired, async (req, res) => {
const { employee_id, transaction_date, collection_amount, deposit_amount, currency } = req.body || {};
if (!employee_id || !transaction_date) {
return res.status(400).json({ error: 'employee_id and transaction_date required' });
}
try {
const { rows } = await pool.query(
`INSERT INTO app.employee_transactions
(employee_id, transaction_date, collection_amount, deposit_amount, currency)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, employee_id, transaction_date, collection_amount, deposit_amount, currency`,
[
employee_id,
transaction_date,
Number(collection_amount) || 0,
Number(deposit_amount) || 0,
currency || 'USD',
],
);
res.json({ data: rows[0] });
} catch (e) { dbError(res, e); }
});
// ---- admin: user management -----------------------------------------
async function ensureAdmin(req, res) {
try {
const { rows } = await pool.query(`
SELECT coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS ok
FROM auth.users u
WHERE u.id = $1
`, [req.user.id]);
const ok = !!rows[0]?.ok;
if (!ok) {
res.status(403).json({ error: 'admin only' });
return false;
}
return true;
} catch (e) { dbError(res, e); return false; }
}
function normalizeUserRole(role) {
if (role === 'admin' || role === 'owner' || role === 'employee') return role;
return 'employee';
}
async function firstShopId(client) {
const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1');
return shop.rows[0]?.id ?? null;
}
async function assignShopRole(client, userId, role, assignedBy, shopIdParam) {
const shopId = shopIdParam || await firstShopId(client);
if (!shopId) return;
await client.query(
`INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`,
[userId, shopId, role, assignedBy],
);
}
async function upsertEmployee(client, { empId, name, email, department }) {
if (!empId) return;
await client.query(
`INSERT INTO app.employees(emp_id, name, email, department)
VALUES ($1, $2, $3, $4)
ON CONFLICT (emp_id) DO UPDATE
SET name = EXCLUDED.name, email = EXCLUDED.email,
department = EXCLUDED.department`,
[empId, name, email, department || null],
);
}
app.get('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
try {
const { rows } = await pool.query(`
SELECT u.id, u.email, u.is_active, u.full_name,
p.full_name AS profile_name,
coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS is_system_admin,
coalesce(
(SELECT a.role::text FROM app.user_shop_assignments a
WHERE a.user_id = u.id ORDER BY (a.role = 'owner') DESC LIMIT 1),
'cashier'
) AS shop_role,
(SELECT e.emp_id FROM app.employees e WHERE e.email = u.email LIMIT 1) AS emp_id,
(SELECT e.department FROM app.employees e WHERE e.email = u.email LIMIT 1) AS department
FROM auth.users u
LEFT JOIN app.user_profiles p ON p.user_id = u.id
ORDER BY u.created_at
`);
res.json({ data: rows });
} catch (e) { dbError(res, e); }
});
app.post('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
const { email, password, name, role, department, empId, shopId } = req.body || {};
if (!email || !password || !name) {
return res.status(400).json({ error: 'email, password, name required' });
}
if (String(password).length < 6) {
return res.status(400).json({ error: 'password must be at least 6 chars' });
}
const userRole = normalizeUserRole(role);
const isAdmin = userRole === 'admin';
const shopRole = userRole === 'employee' ? 'cashier' : 'owner';
const client = await pool.connect();
try {
await client.query('BEGIN');
const hash = await bcrypt.hash(password, 10);
const u = await client.query(
`INSERT INTO auth.users(email, password_hash, full_name, is_system_admin, is_active)
VALUES ($1, $2, $3, $4, true) RETURNING id, email, full_name`,
[String(email).trim().toLowerCase(), hash, name, isAdmin],
);
const userId = u.rows[0].id;
await client.query(
`INSERT INTO app.user_profiles(user_id, full_name, is_active)
VALUES ($1, $2, true)
ON CONFLICT (user_id) DO UPDATE SET full_name = EXCLUDED.full_name`,
[userId, name],
);
if (!isAdmin) {
await assignShopRole(client, userId, shopRole, req.user.id, shopId);
await upsertEmployee(client, { empId, name, email, department });
}
await client.query('COMMIT');
res.json({ data: { id: userId, email: u.rows[0].email, full_name: u.rows[0].full_name } });
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
dbError(res, e);
} finally {
client.release();
}
});
app.delete('/admin/users/:id', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'cannot delete yourself' });
}
try {
await pool.query('DELETE FROM auth.users WHERE id = $1', [req.params.id]);
res.json({ ok: true });
} catch (e) { dbError(res, e); }
});
app.post('/admin/dev/end_of_day/reopen_latest', authRequired, async (req, res) => {
if (!LOCAL_DEV_TOOLS_ENABLED) {
return res.status(404).json({ error: 'not found' });
}
if (!(await ensureAdmin(req, res))) return;
const { shop_id: shopId } = req.body || {};
if (!shopId) {
return res.status(400).json({ error: 'shop_id required' });
}
try {
const r = await pool.query(
`with shop_bounds as (
select
min(business_date) as oldest_business_date,
min(submitted_at) as oldest_submitted_at
from app.end_of_day_reports
where shop_id = $1
),
latest as (
select id
from app.end_of_day_reports
where shop_id = $1
order by submitted_at desc
limit 1
)
update app.end_of_day_reports e
set business_date = shop_bounds.oldest_business_date - interval '1 day',
submitted_at = shop_bounds.oldest_submitted_at - interval '1 day'
from latest, shop_bounds
where e.id = latest.id
returning e.id, e.shop_id, e.business_date, e.submitted_at`,
[shopId],
);
const data = r.rows[0] ?? null;
if (!data) {
return res.status(404).json({ error: 'no end-of-day report found for this shop' });
}
res.json({ data });
} catch (e) { dbError(res, e); }
});
// ---- health ----------------------------------------------------------
app.get('/health', async (_req, res) => {
try { await pool.query('SELECT 1'); res.json({ ok: true }); }
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
// ---- static SPA ------------------------------------------------------
// In production we serve the React build from this same process so there
// is only one port to manage. CORS is not crossed when the SPA and API
// share an origin, which is the whole point on the shop's local box.
if (SERVE_STATIC) {
console.log(`[server] serving SPA from ${STATIC_DIR}`);
app.use(express.static(STATIC_DIR, { index: false, maxAge: '1h' }));
app.get(/^\/(?!auth|rpc|from|employees|employee_transactions|admin|health).*/,
(_req, res) => res.sendFile(path.join(STATIC_DIR, 'index.html')));
}
app.listen(PORT, BIND_HOST, () => {
console.log(`[server] listening on http://${BIND_HOST}:${PORT} (NODE_ENV=${process.env.NODE_ENV || 'development'})`);
});
-42
View File
@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+64 -17
View File
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import React, { useEffect, useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -12,6 +11,8 @@ import { format } from "date-fns";
import { cn } from "@/lib/utils";
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
import { useToast } from "@/hooks/use-toast";
import { Currency } from "@/lib/currency";
import { useAuth } from "@/hooks/useAuth";
interface AdminDataEntryModalProps {
isOpen: boolean;
@@ -21,14 +22,27 @@ interface AdminDataEntryModalProps {
export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminDataEntryModalProps) => {
const { employees, addTransaction } = useSupabaseEmployeeData();
const { user } = useAuth();
const { toast } = useToast();
const isSelfEntryRole = user?.role === 'employee' || user?.role === 'owner';
const lockedEmployeeId = isSelfEntryRole
? employees.find(e => e.emp_id === user?.empId || e.email === user?.email)?.id
: undefined;
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>('');
const [collectionAmount, setCollectionAmount] = useState<string>('');
const [depositAmount, setDepositAmount] = useState<string>('');
const [currency, setCurrency] = useState<Currency>('USD');
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isSelfEntryRole && lockedEmployeeId) {
setSelectedEmployeeId(lockedEmployeeId);
}
}, [isSelfEntryRole, lockedEmployeeId, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -59,7 +73,8 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
await addTransaction(selectedEmployeeId, {
transaction_date: format(selectedDate, 'yyyy-MM-dd'),
collection_amount: collection,
deposit_amount: deposit
deposit_amount: deposit,
currency,
});
toast({
@@ -71,6 +86,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
setSelectedEmployeeId('');
setCollectionAmount('');
setDepositAmount('');
setCurrency('USD');
setSelectedDate(new Date());
onDataUpdate();
@@ -89,34 +105,52 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
setSelectedEmployeeId('');
setCollectionAmount('');
setDepositAmount('');
setCurrency('USD');
setSelectedDate(new Date());
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px] bg-white">
<DialogContent className="w-[min(96vw,500px)] sm:max-w-[500px] bg-white p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-slate-800">Insert Employee Data</DialogTitle>
<DialogTitle className="text-2xl font-bold text-slate-800">
{isSelfEntryRole ? "Submit Transaction" : "Insert Employee Data"}
</DialogTitle>
<DialogDescription>
Record collection and deposit amounts with the proper date and currency.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 mt-6">
<form onSubmit={handleSubmit} className="space-y-6 mt-4">
<div className="space-y-2">
<Label htmlFor="employee" className="text-sm font-medium text-slate-700">
Select Employee
{isSelfEntryRole ? "Employee" : "Select Employee"}
</Label>
<Select value={selectedEmployeeId} onValueChange={setSelectedEmployeeId}>
<Select
value={selectedEmployeeId}
onValueChange={setSelectedEmployeeId}
disabled={isSelfEntryRole}
>
<SelectTrigger>
<SelectValue placeholder="Choose an employee" />
<SelectValue placeholder={isSelfEntryRole ? "Your account" : "Choose an employee"} />
</SelectTrigger>
<SelectContent>
{employees.map(employee => (
{(isSelfEntryRole && lockedEmployeeId
? employees.filter(e => e.id === lockedEmployeeId)
: employees
).map(employee => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name} (ID: {employee.emp_id})
</SelectItem>
))}
</SelectContent>
</Select>
{isSelfEntryRole && !lockedEmployeeId && (
<p className="text-xs text-red-600">
Your account is not linked to an employee record. Ask an admin to set your Employee ID.
</p>
)}
</div>
<div className="space-y-2">
@@ -148,10 +182,23 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
</Popover>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-slate-700">Currency</Label>
<Select value={currency} onValueChange={(v) => setCurrency(v as Currency)}>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">US Dollar (USD)</SelectItem>
<SelectItem value="LBP">Lebanese Pound (LBP)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="collection" className="text-sm font-medium text-slate-700">
MM Collection Amount ()
MM Collection Amount ({currency})
</Label>
<Input
id="collection"
@@ -160,7 +207,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
value={collectionAmount}
onChange={(e) => setCollectionAmount(e.target.value)}
className="text-right"
step="0.01"
step={currency === 'USD' ? '0.01' : '1'}
min="0"
disabled={loading}
/>
@@ -168,7 +215,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
<div className="space-y-2">
<Label htmlFor="deposit" className="text-sm font-medium text-slate-700">
Deposit Amount ()
Deposit Amount ({currency})
</Label>
<Input
id="deposit"
@@ -177,14 +224,14 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
value={depositAmount}
onChange={(e) => setDepositAmount(e.target.value)}
className="text-right"
step="0.01"
step={currency === 'USD' ? '0.01' : '1'}
min="0"
disabled={loading}
/>
</div>
</div>
<div className="flex justify-end space-x-3 pt-4">
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
Cancel
</Button>
+213
View File
@@ -0,0 +1,213 @@
import React, { useEffect, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { supabase } from "@/integrations/supabase/client";
/**
* FxSwapDialog cashier swaps cash between USD and LBP at the
* currently posted rate. Server-side validates the rate against the
* active fx_rates band, requires the two legs to balance within 1 LBP,
* and posts both cash_movements rows + a FX_SWAP transaction atomically.
*/
export const FxSwapDialog: React.FC<{
open: boolean;
onClose: () => void;
shopId: string;
tillId: string;
defaultRate?: string;
onDone?: () => void;
}> = ({ open, onClose, shopId, tillId, defaultRate, onDone }) => {
const { toast } = useToast();
const [direction, setDirection] = useState<"sell_usd" | "buy_usd">("sell_usd");
const [usdAmount, setUsdAmount] = useState("");
const [lbpAmount, setLbpAmount] = useState("");
const [rate, setRate] = useState(defaultRate ?? "");
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
// Fetch current FX rate when opened.
useEffect(() => {
if (!open || !shopId) return;
(async () => {
const { data } = await supabase
.from("fx_rates")
.select("usd_to_lbp_rate, effective_to")
.eq("shop_id", shopId);
const active = (data ?? []).find((r: { effective_to: string | null }) => !r.effective_to);
if (active) setRate(String((active as { usd_to_lbp_rate: number }).usd_to_lbp_rate));
})();
}, [open, shopId]);
// Auto-compute the opposite leg whenever rate or one amount changes.
const computeFromUsd = (usd: string) => {
setUsdAmount(usd);
const r = Number(rate);
const u = Number(usd);
if (r > 0 && u > 0) setLbpAmount(String(Math.round(u * r)));
};
const computeFromLbp = (lbp: string) => {
setLbpAmount(lbp);
const r = Number(rate);
const l = Number(lbp);
if (r > 0 && l > 0) setUsdAmount((l / r).toFixed(2));
};
const submit = async () => {
const u = Number(usdAmount);
const l = Number(lbpAmount);
const r = Number(rate);
if (!u || !l || !r || !shopId || !tillId) {
toast({ title: "Fill all fields", variant: "destructive" });
return;
}
setBusy(true);
try {
// direction === sell_usd: USD leaves till (positive usd_out), LBP enters (positive lbp_in)
// direction === buy_usd: USD enters till (negative usd_out), LBP leaves (negative lbp_in)
const usdOut = direction === "sell_usd" ? u : -u;
const lbpIn = direction === "sell_usd" ? l : -l;
const { error } = await supabase.rpc("record_fx_swap", {
p_shop: shopId,
p_till: tillId,
p_usd_out: usdOut,
p_lbp_in: lbpIn,
p_fx_rate: r,
p_notes: notes.trim() || null,
});
if (error) throw error;
toast({ title: "FX swap recorded" });
setUsdAmount(""); setLbpAmount(""); setNotes("");
onDone?.();
onClose();
} catch (e) {
toast({ title: "Swap failed", description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="w-[min(96vw,480px)] sm:max-w-[480px] bg-white p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-slate-800">Cash FX Swap</DialogTitle>
<DialogDescription>
Convert till cash between USD and LBP at the posted rate.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<Label>Direction</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as "sell_usd" | "buy_usd")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sell_usd">Sell USD take LBP</SelectItem>
<SelectItem value="buy_usd">Buy USD give LBP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label>USD amount</Label>
<Input type="number" step="0.01" value={usdAmount}
onChange={(e) => computeFromUsd(e.target.value)} />
</div>
<div>
<Label>LBP amount</Label>
<Input type="number" step="1" value={lbpAmount}
onChange={(e) => computeFromLbp(e.target.value)} />
</div>
</div>
<div>
<Label>Rate (USD LBP)</Label>
<Input type="number" value={rate} onChange={(e) => setRate(e.target.value)} />
<p className="text-xs text-slate-500 mt-1">
Must match the currently posted rate within tolerance.
</p>
</div>
<div>
<Label>Notes</Label>
<Input value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={submit} disabled={busy || !usdAmount || !lbpAmount}>
{busy ? "Posting…" : "Post swap"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
/**
* SelfDealOverrideDialog manager enters their PIN to authorize the
* NEXT money-transfer transaction even if the cashier matches the
* sender/beneficiary KYC. The override is stored in a session GUC and
* automatically cleared by the trigger after one use.
*/
export const SelfDealOverrideDialog: React.FC<{
open: boolean;
onClose: () => void;
shopId: string;
onGranted?: () => void;
}> = ({ open, onClose, shopId, onGranted }) => {
const { toast } = useToast();
const [pin, setPin] = useState("");
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!pin || !shopId) return;
setBusy(true);
try {
const { error } = await supabase.rpc("manager_allow_next_self_deal", {
p_manager_pin: pin,
p_shop: shopId,
});
if (error) throw error;
toast({
title: "Override granted",
description: "The next self-deal transfer in this session will be allowed.",
});
setPin("");
onGranted?.();
onClose();
} catch (e) {
toast({ title: "Override denied", description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="w-[min(96vw,420px)] sm:max-w-[420px] bg-white p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-slate-800">Manager Self-Deal Override</DialogTitle>
<DialogDescription>
Enter the manager / owner PIN to allow exactly one transfer
where the cashier is sender or beneficiary. The override is
single-use and audit-logged.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<Label>Manager PIN</Label>
<Input type="password" value={pin} onChange={(e) => setPin(e.target.value)} autoFocus />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
<Button onClick={submit} disabled={busy || !pin || !shopId}>
{busy ? "Verifying…" : "Authorize next"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
import { Currency, convert, formatCurrency, getUsdToLbpRate } from "@/lib/currency";
interface DetailedTransaction {
location: string;
@@ -17,18 +17,12 @@ interface DetailedTransaction {
}
export const DetailedEmployeePaymentReport = () => {
const { employees, transactions } = useSupabaseEmployeeData();
const [displayCurrency, setDisplayCurrency] = useState<Currency>("USD");
const { employees, transactions } = useSupabaseEmployeeData(displayCurrency);
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>('all');
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR'
}).format(amount);
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('en-IN');
return new Date(date).toLocaleDateString('en-US');
};
// Process transactions with the specific business logic
@@ -38,6 +32,11 @@ export const DetailedEmployeePaymentReport = () => {
const employeeTransactions = transactions
.filter(t => t.employee_id === employeeId)
.map(t => ({
...t,
collection_amount: convert(t.collection_amount, t.currency, displayCurrency),
deposit_amount: convert(t.deposit_amount, t.currency, displayCurrency),
}))
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const detailedTransactions: DetailedTransaction[] = [];
@@ -62,7 +61,7 @@ export const DetailedEmployeePaymentReport = () => {
if (collections > 0) {
pendingCollections.push({ amount: collections, date });
detailedTransactions.push({
location: 'BGRoad, Karnataka',
location: employee.location || 'BGRoad, Karnataka',
empId: employee.emp_id.replace('EMP', ''),
empName: employee.name,
collectionAmount: collections,
@@ -106,7 +105,7 @@ export const DetailedEmployeePaymentReport = () => {
// If there's remaining deposit after clearing collections, add separate deposit entries
while (remainingDeposit > 0) {
detailedTransactions.push({
location: 'BGRoad, Karnataka',
location: employee.location || 'BGRoad, Karnataka',
empId: employee.emp_id.replace('EMP', ''),
empName: employee.name,
collectionAmount: 0,
@@ -133,30 +132,54 @@ export const DetailedEmployeePaymentReport = () => {
const detailedTransactions = getTransactionsToShow();
// Calculate totals
// Calculate totals (combined, in display currency)
const totalCollection = detailedTransactions.reduce((sum, t) => sum + t.collectionAmount, 0);
const totalDeposit = detailedTransactions.reduce((sum, t) => sum + t.depositAmount, 0);
const totalDifference = totalDeposit - totalCollection;
// Per-currency totals from raw transactions, filtered to current selection
const filteredRawTx = selectedEmployeeId === 'all'
? transactions
: transactions.filter(t => t.employee_id === selectedEmployeeId);
const totalCollectionUSD = filteredRawTx.filter(t => t.currency === 'USD').reduce((s, t) => s + t.collection_amount, 0);
const totalCollectionLBP = filteredRawTx.filter(t => t.currency === 'LBP').reduce((s, t) => s + t.collection_amount, 0);
const totalDepositUSD = filteredRawTx.filter(t => t.currency === 'USD').reduce((s, t) => s + t.deposit_amount, 0);
const totalDepositLBP = filteredRawTx.filter(t => t.currency === 'LBP').reduce((s, t) => s + t.deposit_amount, 0);
const totalDifferenceUSD = totalDepositUSD - totalCollectionUSD;
const totalDifferenceLBP = totalDepositLBP - totalCollectionLBP;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-purple-600">Employee Payment Report (Detailed)</h2>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">Rate: 1 USD = {getUsdToLbpRate().toLocaleString()} LBP</span>
<Select value={displayCurrency} onValueChange={(v) => setDisplayCurrency(v as Currency)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">View in USD</SelectItem>
<SelectItem value="LBP">View in LBP</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-6 mb-6">
<Card className="bg-white shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center shrink-0">
<div className="w-6 h-6 bg-gray-600 rounded-full"></div>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Total Collection</p>
<p className="text-sm text-gray-500">(MM) Amount</p>
<p className="text-2xl font-bold text-gray-800">{formatCurrency(totalCollection)}</p>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-500 mb-1">Total Collection (MM)</p>
<p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionUSD, "USD")}</span></p>
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionLBP, "LBP")}</span></p>
<p className="text-lg font-bold text-gray-800 mt-1"> {formatCurrency(totalCollection, displayCurrency)}</p>
</div>
</div>
</CardContent>
@@ -164,16 +187,17 @@ export const DetailedEmployeePaymentReport = () => {
<Card className="bg-white shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center shrink-0">
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs"></span>
</div>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Total Deposit</p>
<p className="text-sm text-gray-500">Amount</p>
<p className="text-2xl font-bold text-gray-800">{formatCurrency(totalDeposit)}</p>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-500 mb-1">Total Deposit Amount</p>
<p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalDepositUSD, "USD")}</span></p>
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalDepositLBP, "LBP")}</span></p>
<p className="text-lg font-bold text-gray-800 mt-1"> {formatCurrency(totalDeposit, displayCurrency)}</p>
</div>
</div>
</CardContent>
@@ -181,17 +205,18 @@ export const DetailedEmployeePaymentReport = () => {
<Card className="bg-white shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center shrink-0">
<div className="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs">=</span>
</div>
</div>
<div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-500 mb-1">Net Difference</p>
<p className="text-sm text-gray-500">Amount</p>
<p className={`text-2xl font-bold ${totalDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(totalDifference)}
<p className="text-xs text-gray-500">USD: <span className={`font-medium ${totalDifferenceUSD >= 0 ? 'text-green-600' : 'text-red-600'}`}>{formatCurrency(totalDifferenceUSD, "USD")}</span></p>
<p className="text-xs text-gray-500">LBP: <span className={`font-medium ${totalDifferenceLBP >= 0 ? 'text-green-600' : 'text-red-600'}`}>{formatCurrency(totalDifferenceLBP, "LBP")}</span></p>
<p className={`text-lg font-bold mt-1 ${totalDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(totalDifference, displayCurrency)}
</p>
</div>
</div>
@@ -239,13 +264,13 @@ export const DetailedEmployeePaymentReport = () => {
<TableCell className="font-medium py-4">{transaction.empId}</TableCell>
<TableCell className="py-4 font-medium text-gray-800">{transaction.empName}</TableCell>
<TableCell className="py-4 font-medium">
{transaction.collectionAmount > 0 ? transaction.collectionAmount.toLocaleString() : '-'}
{transaction.collectionAmount > 0 ? formatCurrency(transaction.collectionAmount, displayCurrency) : '-'}
</TableCell>
<TableCell className="py-4 text-gray-600">
{transaction.collectionDate ? formatDate(transaction.collectionDate) : '-'}
</TableCell>
<TableCell className="py-4 font-medium">
{transaction.depositAmount > 0 ? transaction.depositAmount.toLocaleString() : '-'}
{transaction.depositAmount > 0 ? formatCurrency(transaction.depositAmount, displayCurrency) : '-'}
</TableCell>
<TableCell className="py-4 text-gray-600">
{transaction.depositDate ? formatDate(transaction.depositDate) : '-'}
@@ -255,7 +280,7 @@ export const DetailedEmployeePaymentReport = () => {
transaction.difference === 0 ? 'text-gray-600' :
transaction.difference > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{transaction.difference === 0 ? '-' : transaction.difference.toLocaleString()}
{transaction.difference === 0 ? '-' : formatCurrency(transaction.difference, displayCurrency)}
</span>
</TableCell>
</TableRow>
+15 -15
View File
@@ -10,8 +10,8 @@ interface LoginPageProps {
}
export const LoginPage = ({ onLogin }: LoginPageProps) => {
const [email, setEmail] = useState('admin@astra.in');
const [password, setPassword] = useState('123456');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const { signIn } = useAuth();
@@ -21,13 +21,10 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
e.preventDefault();
setLoading(true);
console.log('Attempting login with:', { email, password });
try {
const { error } = await signIn(email, password);
if (error) {
console.error('Login error:', error);
toast({
title: "Login Error",
description: error.message,
@@ -43,7 +40,6 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
onLogin();
} catch (err) {
console.error('Unexpected error:', err);
toast({
title: "Error",
description: "An unexpected error occurred",
@@ -57,13 +53,23 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
return (
<div className="min-h-screen flex">
{/* Left side with purple gradient */}
<div className="flex-1 bg-gradient-to-br from-purple-600 via-purple-700 to-purple-800 flex items-center justify-center text-white p-12">
<div
className="bg-gradient-to-br flex flex-1 items-center justify-center p-12 text-white"
style={{
'--tw-gradient-from': '#4C4895',
'--tw-gradient-to': '#37337b',
'--tw-gradient-stops': 'var(--tw-gradient-from), var(--tw-gradient-to)',
} as React.CSSProperties}
>
<div className="max-w-md">
<h1 className="text-4xl font-bold mb-4">Cash Management Dashboard</h1>
<p className="text-lg text-purple-100">Real-time Cash Reconciliation Ensuring Accuracy & Transparency</p>
<p className="text-lg text-purple-100">
Real-time Cash Reconciliation Ensuring Accuracy &amp; Transparency
</p>
</div>
</div>
{/* Right side with login form */}
<div className="flex-1 bg-white flex items-center justify-center p-12">
<div className="w-full max-w-md">
@@ -73,7 +79,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
<div>
<Input
type="email"
placeholder="admin@astra.in"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full h-12 px-4 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-purple-500"
@@ -100,12 +106,6 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
{loading ? "Signing in..." : "Login to dashboard"}
</Button>
</form>
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600 mb-2">Default Login Credentials:</p>
<p className="text-sm font-mono">Email: admin@astra.in</p>
<p className="text-sm font-mono">Password: 123456</p>
</div>
</div>
</div>
</div>
+909
View File
@@ -0,0 +1,909 @@
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/integrations/supabase/client";
type Currency = "USD" | "LBP";
type IdDocType = "lebanese_id" | "passport" | "residence_permit" | "driver_license" | "other";
const SERVICE_CODES = [
"OMT_SEND", "OMT_RECEIVE", "WU_SEND", "WU_RECEIVE",
"WHISH_SEND", "WHISH_RECEIVE", "OMT_BILL", "EDL_BILL",
"ALFA_RECHARGE", "TOUCH_RECHARGE", "GOODS_SALE", "REPAIR",
];
interface ShopUser {
user_id: string;
full_name: string;
role: string;
}
interface FeeRow {
id: string;
service_code: string;
currency: Currency;
min_amount: number;
max_amount: number;
fee_fixed: number;
fee_pct: number;
commission_fixed: number;
commission_pct: number;
effective_from: string;
effective_to: string | null;
}
interface FxRow {
id: string;
usd_to_lbp_rate: number;
tolerance_pct: number;
effective_from: string;
effective_to: string | null;
}
interface SafeBalanceRow {
shop_id: string;
shop_name: string;
safe_id: string;
safe_name: string;
currency: Currency | null;
balance: number;
updated_at: string | null;
}
interface BankDepositRow {
id: string;
amount: number;
currency: Currency;
bank_ref: string | null;
notes: string | null;
created_at: string;
}
interface ServiceUiRow {
shop_id: string;
service_code: string;
default_name: string;
category: string;
is_active: boolean;
display_name: string | null;
icon: string | null;
updated_at: string | null;
}
/**
* ManagerConsole single screen for managers/owners to:
* - Seed / view fee_schedule rows (per service per currency).
* - Seed / view fx_rates rows.
* - Set cashier KYC (id_type, id_number, phone) used by self-deal blocking.
* - Record a bank deposit out of the safe and view safe balances.
*
* All writes go through SECURITY DEFINER RPCs from migrations 0021/0023/0024/0025;
* direct table writes are blocked by RLS.
*/
export const ManagerConsole: React.FC = () => {
const { user } = useAuth();
const { toast } = useToast();
const managerShops = useMemo(
() => (user?.shops ?? []).filter(s => s.role === "manager" || s.role === "owner"),
[user]
);
const [shopId, setShopId] = useState<string>("");
useEffect(() => {
if (managerShops.length && !shopId) setShopId(managerShops[0].shop_id);
}, [managerShops, shopId]);
if (!managerShops.length) {
return (
<Card>
<CardContent className="py-8 text-center text-slate-500">
You don&apos;t have manager or owner role in any shop.
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{managerShops.length > 1 && (
<div className="flex items-center gap-2">
<Label>Shop:</Label>
<Select value={shopId} onValueChange={setShopId}>
<SelectTrigger className="w-72"><SelectValue /></SelectTrigger>
<SelectContent>
{managerShops.map(s => (
<SelectItem key={s.shop_id} value={s.shop_id}>
{s.shop_name} · {s.role}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Tabs defaultValue="fees" className="space-y-4">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="fees">Fee Schedule</TabsTrigger>
<TabsTrigger value="fx">FX Rates</TabsTrigger>
<TabsTrigger value="services">Service Titles</TabsTrigger>
<TabsTrigger value="tills">Tills</TabsTrigger>
<TabsTrigger value="kyc">Cashier KYC</TabsTrigger>
<TabsTrigger value="safe">Safe / Bank</TabsTrigger>
</TabsList>
<TabsContent value="fees">
<FeeScheduleTab shopId={shopId} />
</TabsContent>
<TabsContent value="fx">
<FxRatesTab shopId={shopId} />
</TabsContent>
<TabsContent value="services">
<ServiceTitlesTab shopId={shopId} />
</TabsContent>
<TabsContent value="tills">
<TillsTab shopId={shopId} />
</TabsContent>
<TabsContent value="kyc">
<KycTab shopId={shopId} />
</TabsContent>
<TabsContent value="safe">
<SafeAndBankTab shopId={shopId} />
</TabsContent>
</Tabs>
</div>
);
};
// ---------------------------------------------------------------------------
// Service title/icon tab
// ---------------------------------------------------------------------------
const ServiceTitlesTab: React.FC<{ shopId: string }> = ({ shopId }) => {
const { toast } = useToast();
const [rows, setRows] = useState<ServiceUiRow[]>([]);
const [drafts, setDrafts] = useState<Record<string, { display_name: string; icon: string }>>({});
const [busyCode, setBusyCode] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!shopId) return;
const { data, error } = await supabase
.from("v_service_ui_settings")
.select("shop_id, service_code, default_name, category, is_active, display_name, icon, updated_at")
.eq("shop_id", shopId);
if (error) {
toast({ title: "Could not load service titles", description: error.message, variant: "destructive" });
return;
}
const nextRows = ((data ?? []) as ServiceUiRow[])
.slice()
.sort((a, b) => `${a.category}:${a.default_name}`.localeCompare(`${b.category}:${b.default_name}`));
setRows(nextRows);
setDrafts(Object.fromEntries(nextRows.map((row) => [row.service_code, {
display_name: row.display_name ?? "",
icon: row.icon ?? "",
}])));
}, [shopId, toast]);
useEffect(() => { refresh(); }, [refresh]);
const updateDraft = (serviceCode: string, patch: Partial<{ display_name: string; icon: string }>) => {
setDrafts((current) => ({
...current,
[serviceCode]: {
display_name: current[serviceCode]?.display_name ?? "",
icon: current[serviceCode]?.icon ?? "",
...patch,
},
}));
};
const save = async (row: ServiceUiRow) => {
if (!shopId) return;
const draft = drafts[row.service_code] ?? { display_name: "", icon: "" };
setBusyCode(row.service_code);
const { error } = await supabase.rpc("set_service_ui_setting", {
p_shop: shopId,
p_service_code: row.service_code,
p_display_name: draft.display_name,
p_icon: draft.icon,
});
setBusyCode(null);
if (error) {
toast({ title: "Could not save service title", description: error.message, variant: "destructive" });
return;
}
toast({ title: "Service display saved", description: row.service_code });
await refresh();
};
return (
<Card>
<CardHeader><CardTitle>Service Titles & Icons</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-slate-500">
Customize how transaction buttons and receipt lists appear for this shop. Service codes stay unchanged for accounting.
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead>Default title</TableHead>
<TableHead>Display title</TableHead>
<TableHead>Icon</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const draft = drafts[row.service_code] ?? { display_name: "", icon: "" };
return (
<TableRow key={row.service_code}>
<TableCell>
<div className="font-medium">{row.service_code}</div>
<div className="text-xs text-slate-500">{row.category}</div>
</TableCell>
<TableCell>{row.default_name}</TableCell>
<TableCell>
<Input
value={draft.display_name}
placeholder={row.default_name}
maxLength={80}
onChange={(event) => updateDraft(row.service_code, { display_name: event.target.value })}
/>
</TableCell>
<TableCell>
<Input
value={draft.icon}
placeholder="Emoji or short symbol"
maxLength={16}
onChange={(event) => updateDraft(row.service_code, { icon: event.target.value })}
/>
</TableCell>
<TableCell className="text-right">
<Button onClick={() => save(row)} disabled={busyCode === row.service_code || !shopId}>
{busyCode === row.service_code ? "Saving..." : "Save"}
</Button>
</TableCell>
</TableRow>
);
})}
{!rows.length && (
<TableRow>
<TableCell colSpan={5} className="text-center text-slate-500 py-6">
No services available.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
};
// ---------------------------------------------------------------------------
// Fee schedule tab
// ---------------------------------------------------------------------------
const FeeScheduleTab: React.FC<{ shopId: string }> = ({ shopId }) => {
const { toast } = useToast();
const [rows, setRows] = useState<FeeRow[]>([]);
const [busy, setBusy] = useState(false);
const [serviceCode, setServiceCode] = useState("OMT_SEND");
const [currency, setCurrency] = useState<Currency>("USD");
const [minAmount, setMinAmount] = useState("0");
const [maxAmount, setMaxAmount] = useState("999999999");
const [feeFixed, setFeeFixed] = useState("0");
const [feePct, setFeePct] = useState("0");
const [commFixed, setCommFixed] = useState("0");
const [commPct, setCommPct] = useState("0");
const refresh = useCallback(async () => {
if (!shopId) return;
const { data, error } = await supabase
.from("fee_schedule")
.select("id, service_code, currency, min_amount, max_amount, fee_fixed, fee_pct, commission_fixed, commission_pct, effective_from, effective_to")
.eq("shop_id", shopId)
.order("service_code")
.order("effective_from", { ascending: false });
if (error) {
toast({ title: "Could not load fee schedule", description: error.message, variant: "destructive" });
return;
}
setRows((data ?? []) as FeeRow[]);
}, [shopId, toast]);
useEffect(() => { refresh(); }, [refresh]);
const submit = async () => {
if (!shopId) return;
setBusy(true);
try {
const { error } = await supabase.rpc("set_fee_bracket", {
p_shop: shopId,
p_service_code: serviceCode,
p_currency: currency,
p_min_amount: Number(minAmount) || 0,
p_max_amount: Number(maxAmount) || 0,
p_fee_fixed: Number(feeFixed) || 0,
p_fee_pct: Number(feePct) || 0,
p_commission_fixed: Number(commFixed) || 0,
p_commission_pct: Number(commPct) || 0,
});
if (error) throw error;
toast({ title: "Fee bracket saved" });
await refresh();
} catch (e) {
toast({ title: "Save failed", description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
return (
<Card>
<CardHeader><CardTitle>Fee Schedule</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<Label>Service</Label>
<Select value={serviceCode} onValueChange={setServiceCode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SERVICE_CODES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Currency</Label>
<Select value={currency} onValueChange={v => setCurrency(v as Currency)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="LBP">LBP</SelectItem>
</SelectContent>
</Select>
</div>
<div><Label>Min amount</Label><Input value={minAmount} onChange={e => setMinAmount(e.target.value)} /></div>
<div><Label>Max amount</Label><Input value={maxAmount} onChange={e => setMaxAmount(e.target.value)} /></div>
<div><Label>Fee fixed</Label><Input value={feeFixed} onChange={e => setFeeFixed(e.target.value)} /></div>
<div><Label>Fee %</Label><Input value={feePct} onChange={e => setFeePct(e.target.value)} /></div>
<div><Label>Commission fixed</Label><Input value={commFixed} onChange={e => setCommFixed(e.target.value)} /></div>
<div><Label>Commission %</Label><Input value={commPct} onChange={e => setCommPct(e.target.value)} /></div>
</div>
<Button onClick={submit} disabled={busy || !shopId}>
{busy ? "Saving…" : "Save bracket"}
</Button>
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead><TableHead>Curr</TableHead>
<TableHead>Min</TableHead><TableHead>Max</TableHead>
<TableHead>Fee fix</TableHead><TableHead>Fee %</TableHead>
<TableHead>Comm fix</TableHead><TableHead>Comm %</TableHead>
<TableHead>From</TableHead><TableHead>To</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(r => (
<TableRow key={r.id}>
<TableCell>{r.service_code}</TableCell>
<TableCell>{r.currency}</TableCell>
<TableCell>{r.min_amount}</TableCell>
<TableCell>{r.max_amount}</TableCell>
<TableCell>{r.fee_fixed}</TableCell>
<TableCell>{r.fee_pct}</TableCell>
<TableCell>{r.commission_fixed}</TableCell>
<TableCell>{r.commission_pct}</TableCell>
<TableCell>{new Date(r.effective_from).toLocaleDateString()}</TableCell>
<TableCell>{r.effective_to ? new Date(r.effective_to).toLocaleDateString() : "—"}</TableCell>
</TableRow>
))}
{!rows.length && <TableRow><TableCell colSpan={10} className="text-center text-slate-500 py-4">No brackets yet.</TableCell></TableRow>}
</TableBody>
</Table>
</CardContent>
</Card>
);
};
// ---------------------------------------------------------------------------
// FX rates tab
// ---------------------------------------------------------------------------
const FxRatesTab: React.FC<{ shopId: string }> = ({ shopId }) => {
const { toast } = useToast();
const [rows, setRows] = useState<FxRow[]>([]);
const [rate, setRate] = useState("89500");
const [tol, setTol] = useState("1.0");
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
if (!shopId) return;
const { data, error } = await supabase
.from("fx_rates")
.select("id, usd_to_lbp_rate, tolerance_pct, effective_from, effective_to")
.eq("shop_id", shopId)
.order("effective_from", { ascending: false });
if (error) {
toast({ title: "Could not load FX rates", description: error.message, variant: "destructive" });
return;
}
setRows((data ?? []) as FxRow[]);
}, [shopId, toast]);
useEffect(() => { refresh(); }, [refresh]);
const submit = async () => {
if (!shopId) return;
setBusy(true);
try {
const { error } = await supabase.rpc("set_fx_rate", {
p_shop: shopId,
p_usd_to_lbp: Number(rate),
p_tolerance_pct: Number(tol),
});
if (error) throw error;
toast({ title: "FX rate saved" });
await refresh();
} catch (e) {
toast({ title: "Save failed", description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
return (
<Card>
<CardHeader><CardTitle>FX Rates (USD LBP)</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div><Label>USD LBP rate</Label><Input value={rate} onChange={e => setRate(e.target.value)} /></div>
<div><Label>Tolerance %</Label><Input value={tol} onChange={e => setTol(e.target.value)} /></div>
<div className="flex items-end">
<Button onClick={submit} disabled={busy || !shopId}>{busy ? "Saving…" : "Post new rate"}</Button>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Rate</TableHead><TableHead>Tolerance</TableHead>
<TableHead>From</TableHead><TableHead>To</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(r => (
<TableRow key={r.id}>
<TableCell>{r.usd_to_lbp_rate.toLocaleString()}</TableCell>
<TableCell>{r.tolerance_pct}%</TableCell>
<TableCell>{new Date(r.effective_from).toLocaleString()}</TableCell>
<TableCell>{r.effective_to ? new Date(r.effective_to).toLocaleString() : "active"}</TableCell>
</TableRow>
))}
{!rows.length && <TableRow><TableCell colSpan={4} className="text-center text-slate-500 py-4">No rates yet.</TableCell></TableRow>}
</TableBody>
</Table>
</CardContent>
</Card>
);
};
// ---------------------------------------------------------------------------
// Cashier KYC tab
// ---------------------------------------------------------------------------
const KycTab: React.FC<{ shopId: string }> = ({ shopId }) => {
const { toast } = useToast();
const [users, setUsers] = useState<ShopUser[]>([]);
const [userId, setUserId] = useState("");
const [idType, setIdType] = useState<IdDocType>("lebanese_id");
const [idNumber, setIdNumber] = useState("");
const [phone, setPhone] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!shopId) { setUsers([]); return; }
let cancelled = false;
(async () => {
const { data, error } = await supabase.rpc("get_shop_users", { p_shop_id: shopId });
if (cancelled) return;
if (error) {
toast({ title: "Could not load users", description: error.message, variant: "destructive" });
return;
}
const formatted = ((data ?? []) as Array<{user_id: string; full_name: string | null; role: string}>).map(r => ({
user_id: r.user_id,
full_name: r.full_name ?? "Unknown",
role: r.role,
}));
setUsers(formatted);
})();
return () => { cancelled = true; };
}, [shopId, toast]);
const submit = async () => {
if (!shopId || !userId || !idNumber.trim()) {
toast({ title: "User and ID number required", variant: "destructive" });
return;
}
setBusy(true);
try {
const { error } = await supabase.rpc("set_user_kyc", {
p_user_id: userId,
p_shop: shopId,
p_id_type: idType,
p_id_number: idNumber.trim(),
p_phone_kyc: phone.trim() || null,
});
if (error) throw error;
toast({ title: "KYC saved" });
setIdNumber(""); setPhone("");
} catch (e) {
toast({ title: "Save failed", description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
return (
<Card>
<CardHeader><CardTitle>Cashier KYC (used for self-deal blocking)</CardTitle></CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-slate-500">
Recording a cashier&apos;s ID and phone here lets the database refuse any
OMT/WU/Whish transfer where the cashier is the sender or beneficiary.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="md:col-span-2">
<Label>User</Label>
<Select value={userId} onValueChange={setUserId}>
<SelectTrigger><SelectValue placeholder="Pick a user" /></SelectTrigger>
<SelectContent>
{users.map(u => (
<SelectItem key={u.user_id} value={u.user_id}>
{u.full_name} · {u.role}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>ID type</Label>
<Select value={idType} onValueChange={v => setIdType(v as IdDocType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="lebanese_id">Lebanese ID</SelectItem>
<SelectItem value="passport">Passport</SelectItem>
<SelectItem value="residence_permit">Residence permit</SelectItem>
<SelectItem value="driver_license">Driver license</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div><Label>ID number</Label><Input value={idNumber} onChange={e => setIdNumber(e.target.value)} /></div>
<div className="md:col-span-2"><Label>Phone (KYC)</Label><Input value={phone} onChange={e => setPhone(e.target.value)} placeholder="+961…" /></div>
<div className="flex items-end">
<Button onClick={submit} disabled={busy || !shopId || !userId}>
{busy ? "Saving…" : "Save KYC"}
</Button>
</div>
</div>
</CardContent>
</Card>
);
};
// ---------------------------------------------------------------------------
// Safe + bank deposit tab
// ---------------------------------------------------------------------------
const SafeAndBankTab: React.FC<{ shopId: string }> = ({ shopId }) => {
const { toast } = useToast();
const [balances, setBalances] = useState<SafeBalanceRow[]>([]);
const [deposits, setDeposits] = useState<BankDepositRow[]>([]);
const [currency, setCurrency] = useState<Currency>("USD");
const [amount, setAmount] = useState("");
const [bankRef, setBankRef] = useState("");
const [slipUrl, setSlipUrl] = useState("");
const [notes, setNotes] = useState("");
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
if (!shopId) return;
const [bal, dep] = await Promise.all([
supabase.from("v_safe_balance").select("*").eq("shop_id", shopId),
supabase
.from("bank_deposits")
.select("id, amount, currency, bank_ref, notes, created_at")
.eq("shop_id", shopId)
.order("created_at", { ascending: false })
.limit(20),
]);
if (bal.error) toast({ title: "Could not load safe balance", description: bal.error.message, variant: "destructive" });
else setBalances((bal.data ?? []) as SafeBalanceRow[]);
if (dep.error) toast({ title: "Could not load deposits", description: dep.error.message, variant: "destructive" });
else setDeposits((dep.data ?? []) as BankDepositRow[]);
}, [shopId, toast]);
useEffect(() => { refresh(); }, [refresh]);
const submit = async () => {
if (!shopId || !amount) return;
setBusy(true);
try {
const { error } = await supabase.rpc("record_bank_deposit", {
p_shop: shopId,
p_currency: currency,
p_amount: Number(amount),
p_bank_ref: bankRef.trim() || null,
p_deposit_slip_url: slipUrl.trim() || null,
p_notes: notes.trim() || null,
});
if (error) throw error;
toast({ title: "Bank deposit recorded" });
setAmount(""); setBankRef(""); setSlipUrl(""); setNotes("");
await refresh();
} catch (e) {
toast({ title: "Deposit failed", description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
return (
<div className="space-y-4">
<Card>
<CardHeader><CardTitle>Safe balance</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow><TableHead>Safe</TableHead><TableHead>Currency</TableHead><TableHead className="text-right">Balance</TableHead><TableHead>Updated</TableHead></TableRow>
</TableHeader>
<TableBody>
{balances.map((b, i) => (
<TableRow key={`${b.safe_id}-${b.currency ?? i}`}>
<TableCell>{b.safe_name}</TableCell>
<TableCell>{b.currency ?? "—"}</TableCell>
<TableCell className="text-right font-mono">{Number(b.balance).toLocaleString()}</TableCell>
<TableCell>{b.updated_at ? new Date(b.updated_at).toLocaleString() : "—"}</TableCell>
</TableRow>
))}
{!balances.length && <TableRow><TableCell colSpan={4} className="text-center text-slate-500 py-4">No safe activity yet.</TableCell></TableRow>}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Record bank deposit</CardTitle></CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<Label>Currency</Label>
<Select value={currency} onValueChange={v => setCurrency(v as Currency)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="LBP">LBP</SelectItem>
</SelectContent>
</Select>
</div>
<div><Label>Amount</Label><Input value={amount} onChange={e => setAmount(e.target.value)} /></div>
<div><Label>Bank ref</Label><Input value={bankRef} onChange={e => setBankRef(e.target.value)} /></div>
<div><Label>Slip URL</Label><Input value={slipUrl} onChange={e => setSlipUrl(e.target.value)} /></div>
<div className="md:col-span-3"><Label>Notes</Label><Input value={notes} onChange={e => setNotes(e.target.value)} /></div>
<div className="flex items-end">
<Button onClick={submit} disabled={busy || !shopId || !amount}>
{busy ? "Posting…" : "Post deposit"}
</Button>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Recent deposits</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow><TableHead>When</TableHead><TableHead>Curr</TableHead><TableHead className="text-right">Amount</TableHead><TableHead>Bank ref</TableHead><TableHead>Notes</TableHead></TableRow>
</TableHeader>
<TableBody>
{deposits.map(d => (
<TableRow key={d.id}>
<TableCell>{new Date(d.created_at).toLocaleString()}</TableCell>
<TableCell>{d.currency}</TableCell>
<TableCell className="text-right font-mono">{Number(d.amount).toLocaleString()}</TableCell>
<TableCell>{d.bank_ref ?? "—"}</TableCell>
<TableCell>{d.notes ?? "—"}</TableCell>
</TableRow>
))}
{!deposits.length && <TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-4">No deposits yet.</TableCell></TableRow>}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
};
// ---------------------------------------------------------------------------
// Tills tab — owner-only create / rename / activate-deactivate
// ---------------------------------------------------------------------------
interface TillRow {
till_id: string;
shop_id: string;
name: string;
is_active: boolean;
created_at: string;
}
const TillsTab: React.FC<{ shopId: string }> = ({ shopId }) => {
const { user } = useAuth();
const { toast } = useToast();
const [rows, setRows] = useState<TillRow[]>([]);
const [busy, setBusy] = useState(false);
const [newName, setNewName] = useState("");
const [editingId, setEditingId] = useState<string>("");
const [editingName, setEditingName] = useState<string>("");
const isOwner = useMemo(
() => (user?.shops ?? []).some(s => s.shop_id === shopId && s.role === "owner"),
[user, shopId]
);
const load = useCallback(async () => {
if (!shopId) return;
const { data, error } = await supabase
.from("v_manage_tills")
.select("till_id, shop_id, name, is_active, created_at")
.eq("shop_id", shopId);
if (error) {
toast({ title: "Could not load tills", description: error.message, variant: "destructive" });
return;
}
setRows(((data ?? []) as TillRow[]).slice().sort((a, b) => a.name.localeCompare(b.name)));
}, [shopId, toast]);
useEffect(() => { load(); }, [load]);
const create = async () => {
const name = newName.trim();
if (!name) return;
setBusy(true);
const { error } = await supabase.rpc("create_till", { p_shop: shopId, p_name: name });
setBusy(false);
if (error) {
toast({ title: "Could not create till", description: error.message, variant: "destructive" });
return;
}
toast({ title: "Till created" });
setNewName("");
load();
};
const rename = async (till_id: string) => {
const name = editingName.trim();
if (!name) return;
setBusy(true);
const { error } = await supabase.rpc("rename_till", { p_till: till_id, p_name: name });
setBusy(false);
if (error) {
toast({ title: "Could not rename till", description: error.message, variant: "destructive" });
return;
}
toast({ title: "Till renamed" });
setEditingId(""); setEditingName("");
load();
};
const setActive = async (till_id: string, active: boolean) => {
setBusy(true);
const { error } = await supabase.rpc("set_till_active", { p_till: till_id, p_active: active });
setBusy(false);
if (error) {
toast({ title: active ? "Could not activate" : "Could not deactivate",
description: error.message, variant: "destructive" });
return;
}
toast({ title: active ? "Till activated" : "Till deactivated" });
load();
};
return (
<Card>
<CardHeader>
<CardTitle>Tills</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!isOwner && (
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded p-3">
Only the shop <strong>owner</strong> can create, rename, or deactivate tills. You can view the list below.
</div>
)}
{isOwner && (
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3 items-end">
<div className="space-y-1.5">
<Label className="text-sm font-medium text-slate-700">New till name</Label>
<Input value={newName} placeholder="e.g. Till 2" onChange={(e) => setNewName(e.target.value)} />
</div>
<Button onClick={create} disabled={busy || !newName.trim()}
className="bg-emerald-600 hover:bg-emerald-700 text-white h-10">
Add Till
</Button>
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((t) => (
<TableRow key={t.till_id}>
<TableCell className="font-medium">
{editingId === t.till_id ? (
<Input value={editingName} onChange={(e) => setEditingName(e.target.value)}
className="h-9" />
) : t.name}
</TableCell>
<TableCell>
<span className={t.is_active
? "inline-block px-2 py-0.5 text-xs rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200"
: "inline-block px-2 py-0.5 text-xs rounded-full bg-slate-100 text-slate-600 border border-slate-200"}>
{t.is_active ? "Active" : "Inactive"}
</span>
</TableCell>
<TableCell className="text-slate-500 text-sm">
{new Date(t.created_at).toLocaleDateString()}
</TableCell>
<TableCell className="text-right space-x-2">
{isOwner && (editingId === t.till_id ? (
<>
<Button size="sm" onClick={() => rename(t.till_id)}
disabled={busy || !editingName.trim()}>Save</Button>
<Button size="sm" variant="outline"
onClick={() => { setEditingId(""); setEditingName(""); }}>Cancel</Button>
</>
) : (
<>
<Button size="sm" variant="outline"
onClick={() => { setEditingId(t.till_id); setEditingName(t.name); }}>
Rename
</Button>
{t.is_active ? (
<Button size="sm" variant="outline"
className="border-rose-300 text-rose-700 hover:bg-rose-50"
onClick={() => setActive(t.till_id, false)} disabled={busy}>
Deactivate
</Button>
) : (
<Button size="sm" variant="outline"
className="border-emerald-300 text-emerald-700 hover:bg-emerald-50"
onClick={() => setActive(t.till_id, true)} disabled={busy}>
Activate
</Button>
)}
</>
))}
</TableCell>
</TableRow>
))}
{!rows.length && (
<TableRow><TableCell colSpan={4} className="text-center text-slate-500 py-4">
No tills yet add one above.
</TableCell></TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
};
+135 -57
View File
@@ -1,13 +1,15 @@
import React from 'react';
import React, { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
import { Currency, convert, formatCurrency, getUsdToLbpRate } from "@/lib/currency";
export const OutstandingReportDashboard = () => {
const { employees, getEmployeeSummary } = useSupabaseEmployeeData();
const [displayCurrency, setDisplayCurrency] = useState<Currency>("USD");
const { employees, getEmployeeSummary, outstandingBalances } = useSupabaseEmployeeData(displayCurrency);
const employeeSummaries = employees.map(employee => {
const legacyEmployeeSummaries = employees.map(employee => {
const summary = getEmployeeSummary(employee.id);
return {
...employee,
@@ -15,40 +17,117 @@ export const OutstandingReportDashboard = () => {
};
});
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR'
}).format(amount);
const balancedSummaries = Array.from(
outstandingBalances.reduce((map, row) => {
const current = map.get(row.employee_id) ?? {
id: row.employee_id,
emp_id: row.emp_id,
name: row.name,
location: row.location || "",
totalCollectionUSD: 0,
totalCollectionLBP: 0,
totalDepositUSD: 0,
totalDepositLBP: 0,
shiftShortageUSD: 0,
shiftShortageLBP: 0,
shiftOverageUSD: 0,
shiftOverageLBP: 0,
totalCollection: 0,
totalDeposit: 0,
outstandingAmount: 0,
lastTransactionDate: null as string | null,
};
if (row.currency === "USD") {
current.totalCollectionUSD += row.total_collection;
current.totalDepositUSD += row.total_deposit;
current.shiftShortageUSD += row.shift_shortage;
current.shiftOverageUSD += row.shift_overage;
} else {
current.totalCollectionLBP += row.total_collection;
current.totalDepositLBP += row.total_deposit;
current.shiftShortageLBP += row.shift_shortage;
current.shiftOverageLBP += row.shift_overage;
}
current.totalCollection += convert(row.total_collection, row.currency, displayCurrency);
current.totalDeposit += convert(row.total_deposit, row.currency, displayCurrency);
current.outstandingAmount += convert(row.outstanding_amount, row.currency, displayCurrency);
if (row.last_activity_at && (!current.lastTransactionDate || row.last_activity_at > current.lastTransactionDate)) {
current.lastTransactionDate = row.last_activity_at;
}
map.set(row.employee_id, current);
return map;
}, new Map<string, {
id: string;
emp_id: string;
name: string;
location: string;
totalCollectionUSD: number;
totalCollectionLBP: number;
totalDepositUSD: number;
totalDepositLBP: number;
shiftShortageUSD: number;
shiftShortageLBP: number;
shiftOverageUSD: number;
shiftOverageLBP: number;
totalCollection: number;
totalDeposit: number;
outstandingAmount: number;
lastTransactionDate: string | null;
}>()).values()
);
const employeeSummaries = outstandingBalances.length ? balancedSummaries : legacyEmployeeSummaries;
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('en-IN');
return new Date(date).toLocaleDateString('en-US');
};
// Calculate totals
// Calculate totals (combined, in display currency)
const totalCollection = employeeSummaries.reduce((sum, emp) => sum + emp.totalCollection, 0);
const totalDeposit = employeeSummaries.reduce((sum, emp) => sum + emp.totalDeposit, 0);
const totalDifference = totalCollection - totalDeposit;
// Per-currency totals (raw, untouched amounts for clarity)
const totalCollectionUSD = employeeSummaries.reduce((s, e) => s + e.totalCollectionUSD, 0);
const totalCollectionLBP = employeeSummaries.reduce((s, e) => s + e.totalCollectionLBP, 0);
const totalDepositUSD = employeeSummaries.reduce((s, e) => s + e.totalDepositUSD, 0);
const totalDepositLBP = employeeSummaries.reduce((s, e) => s + e.totalDepositLBP, 0);
const totalDifferenceUSD = totalCollectionUSD - totalDepositUSD;
const totalDifferenceLBP = totalCollectionLBP - totalDepositLBP;
return (
<div className="space-y-6">
<div className="text-xl font-semibold text-purple-600 mb-4">
<div className="flex items-center justify-between">
<div className="text-xl font-semibold text-purple-600">
Outstanding Report (All Locations)
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">Rate: 1 USD = {getUsdToLbpRate().toLocaleString()} LBP</span>
<Select value={displayCurrency} onValueChange={(v) => setDisplayCurrency(v as Currency)}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">View in USD</SelectItem>
<SelectItem value="LBP">View in LBP</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-3 gap-6 mb-6">
<Card className="bg-white shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center shrink-0">
<div className="w-6 h-6 bg-gray-600 rounded-full"></div>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Total Collection (MM)</p>
<p className="text-sm text-gray-500">(All Locations)</p>
<p className="text-2xl font-bold text-gray-800">{formatCurrency(totalCollection)}</p>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-500 mb-1">Total Collection / Shortage</p>
<p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionUSD, "USD")}</span></p>
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionLBP, "LBP")}</span></p>
<p className="text-lg font-bold text-gray-800 mt-1"> {formatCurrency(totalCollection, displayCurrency)}</p>
</div>
</div>
</CardContent>
@@ -56,16 +135,17 @@ export const OutstandingReportDashboard = () => {
<Card className="bg-white shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center shrink-0">
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs"></span>
</div>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Total Deposit Amount</p>
<p className="text-sm text-gray-500">(All Locations)</p>
<p className="text-2xl font-bold text-gray-800">{formatCurrency(totalDeposit)}</p>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-500 mb-1">Total Deposit / Overage</p>
<p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalDepositUSD, "USD")}</span></p>
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalDepositLBP, "LBP")}</span></p>
<p className="text-lg font-bold text-gray-800 mt-1"> {formatCurrency(totalDeposit, displayCurrency)}</p>
</div>
</div>
</CardContent>
@@ -73,16 +153,17 @@ export const OutstandingReportDashboard = () => {
<Card className="bg-white shadow-sm">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center shrink-0">
<div className="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs">=</span>
</div>
</div>
<div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-500 mb-1">Difference Amount</p>
<p className="text-sm text-gray-500">(All Locations)</p>
<p className="text-2xl font-bold text-red-600">{formatCurrency(totalDifference)}</p>
<p className="text-xs text-gray-500">USD: <span className={`font-medium ${totalDifferenceUSD > 0 ? 'text-red-600' : 'text-green-600'}`}>{formatCurrency(totalDifferenceUSD, "USD")}</span></p>
<p className="text-xs text-gray-500">LBP: <span className={`font-medium ${totalDifferenceLBP > 0 ? 'text-red-600' : 'text-green-600'}`}>{formatCurrency(totalDifferenceLBP, "LBP")}</span></p>
<p className={`text-lg font-bold mt-1 ${totalDifference > 0 ? 'text-red-600' : 'text-green-600'}`}> {formatCurrency(totalDifference, displayCurrency)}</p>
</div>
</div>
</CardContent>
@@ -97,7 +178,8 @@ export const OutstandingReportDashboard = () => {
<TableHead className="font-medium text-gray-600 py-4">Location</TableHead>
<TableHead className="font-medium text-gray-600 py-4">Emp. ID</TableHead>
<TableHead className="font-medium text-gray-600 py-4">Emp. Name</TableHead>
<TableHead className="font-medium text-gray-600 py-4">Collections (MM)</TableHead>
<TableHead className="font-medium text-gray-600 py-4">Collections / Shortage</TableHead>
<TableHead className="font-medium text-gray-600 py-4">Shift Variance</TableHead>
<TableHead className="font-medium text-gray-600 py-4">Date</TableHead>
<TableHead className="font-medium text-gray-600 py-4">Difference</TableHead>
</TableRow>
@@ -105,49 +187,45 @@ export const OutstandingReportDashboard = () => {
<TableBody>
{employeeSummaries.map((employee) => (
<TableRow key={employee.id} className="hover:bg-gray-50 transition-colors duration-150">
<TableCell className="py-4 text-gray-600">BGRoad, Karnataka</TableCell>
<TableCell className="py-4 text-gray-600">{employee.location || 'BGRoad, Karnataka'}</TableCell>
<TableCell className="font-medium py-4">{employee.emp_id.replace('EMP', '')}</TableCell>
<TableCell className="py-4 font-medium text-gray-800">{employee.name}</TableCell>
<TableCell className="py-4 font-medium">
{employee.totalCollection.toLocaleString()}
{formatCurrency(employee.totalCollection, displayCurrency)}
</TableCell>
<TableCell className="py-4 text-xs text-gray-600">
{'shiftShortageUSD' in employee ? (
<div className="space-y-1">
<div>Short USD: <span className="font-medium text-red-600">{formatCurrency(employee.shiftShortageUSD, "USD")}</span></div>
<div>Short LBP: <span className="font-medium text-red-600">{formatCurrency(employee.shiftShortageLBP, "LBP")}</span></div>
<div>Over USD: <span className="font-medium text-green-600">{formatCurrency(employee.shiftOverageUSD, "USD")}</span></div>
<div>Over LBP: <span className="font-medium text-green-600">{formatCurrency(employee.shiftOverageLBP, "LBP")}</span></div>
</div>
) : "Manual only"}
</TableCell>
<TableCell className="py-4 text-gray-600">
{employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '26 Mar 2025'}
{employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '-'}
</TableCell>
<TableCell className="py-4">
<span className={`font-medium ${employee.outstandingAmount > 0 ? 'text-red-600' : 'text-green-600'}`}>
{employee.outstandingAmount > 0 ? employee.outstandingAmount.toLocaleString() : `(${Math.abs(employee.outstandingAmount).toLocaleString()})`}
{employee.outstandingAmount > 0
? formatCurrency(employee.outstandingAmount, displayCurrency)
: `(${formatCurrency(Math.abs(employee.outstandingAmount), displayCurrency)})`}
</span>
</TableCell>
</TableRow>
))}
{employeeSummaries.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-gray-500 py-6">
No outstanding balances yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between pt-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<span>Show</span>
<select className="border border-gray-300 rounded px-3 py-1">
<option>10</option>
<option>25</option>
<option>50</option>
</select>
<span>Rows</span>
</div>
<div className="flex items-center gap-1">
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">1</button>
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">2</button>
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">3</button>
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">4</button>
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">5</button>
<span className="px-2 text-gray-400">...</span>
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">10</button>
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded"></button>
</div>
</div>
{employeeSummaries.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-400 text-lg mb-2">No employee data available</div>
+529
View File
@@ -0,0 +1,529 @@
import React, { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/useAuth";
import { api } from "@/lib/api";
/**
* Owner / Manager overview.
*
* Surfaces what the shop owner actually wants to see when they walk in:
* - per-shop snapshot: open shifts, open alerts, today's gross
* - last closed shifts with their variance USD/LBP
* - 30-day cashier scorecard (variance + voids)
* - open alerts they can acknowledge
*
* All data comes from the existing app.* views/tables wired through the
* Express `/from/:view` route (see ALLOWED_VIEWS in server/src/index.js).
* RLS continues to scope rows to the user's shops.
*/
interface ShopRow {
shop_id: string;
shop_name: string;
open_shifts: number;
open_alerts: number;
critical_alerts: number;
open_recon_exceptions: number;
period_started_at: string;
last_end_of_day_at: string | null;
today_gross_usd: string | number;
today_gross_lbp: string | number;
}
interface EndOfDayReportRow {
id: string;
shop_id: string;
business_date: string;
submitted_at: string;
submitted_by_name: string;
note: string | null;
period_started_at: string;
period_ended_at: string;
completed_txn_count: number;
voided_txn_count: number;
gross_usd: string | number;
gross_lbp: string | number;
safe_drop_usd: string | number;
safe_drop_lbp: string | number;
closed_shift_count: number;
total_variance_usd: string | number;
total_variance_lbp: string | number;
activity_count: number;
activity_log: Array<{
occurred_at: string;
event_type: string;
metadata: Record<string, unknown>;
}>;
}
interface ZRow {
shift_id: string;
shop_id: string;
cashier_id: string;
opened_at: string;
closed_at: string | null;
status: string;
expected_close_usd: string | number;
expected_close_lbp: string | number;
declared_close_usd: string | number | null;
declared_close_lbp: string | number | null;
variance_usd: string | number | null;
variance_lbp: string | number | null;
}
interface ScoreRow {
cashier_id: string;
shop_id: string;
shifts_30d: number;
total_var_usd: string | number;
total_var_lbp: string | number;
voids_30d: number;
}
interface AlertRow {
id: string;
shop_id: string;
kind: string;
severity: "info" | "warn" | "critical";
subject_id: string | null;
payload: Record<string, unknown>;
created_at: string;
acknowledged_at: string | null;
}
const fmtNum = (v: string | number | null | undefined, decimals = 2) => {
const n = Number(v ?? 0);
if (!Number.isFinite(n)) return "0";
return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
};
const varianceTone = (v: string | number | null | undefined) => {
const n = Number(v ?? 0);
if (n === 0) return "text-emerald-700";
if (n < 0) return "text-rose-700 font-semibold";
return "text-amber-700 font-semibold";
};
const fmtBusinessDate = (value: string) => {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString();
};
const isLocalDev = import.meta.env.DEV;
export const OwnerOverview: React.FC = () => {
const { user } = useAuth();
const { toast } = useToast();
const [shops, setShops] = useState<ShopRow[]>([]);
const [zRows, setZRows] = useState<ZRow[]>([]);
const [scores, setScores] = useState<ScoreRow[]>([]);
const [alerts, setAlerts] = useState<AlertRow[]>([]);
const [reports, setReports] = useState<EndOfDayReportRow[]>([]);
const [loading, setLoading] = useState(true);
const [refreshKey, setRefreshKey] = useState(0);
const [submittingShopId, setSubmittingShopId] = useState<string | null>(null);
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
const [shopRes, zRes, scoreRes, alertRes, reportRes] = await Promise.all([
api.fromView<ShopRow>("v_owner_dashboard"),
api.fromView<ZRow>("v_z_report"),
api.fromView<ScoreRow>("v_employee_scorecard_30d"),
api.fromView<AlertRow>("alerts"),
api.fromView<EndOfDayReportRow>("v_end_of_day_reports"),
]);
if (cancelled) return;
setShops(shopRes.data ?? []);
// Most recent first; cap to 25 closed shifts for the table.
const closed = (zRes.data ?? [])
.filter(r => r.status === "closed" && r.closed_at)
.sort((a, b) => (b.closed_at ?? "").localeCompare(a.closed_at ?? ""))
.slice(0, 25);
setZRows(closed);
setScores(scoreRes.data ?? []);
const open = (alertRes.data ?? [])
.filter(a => !a.acknowledged_at)
.sort((a, b) => b.created_at.localeCompare(a.created_at));
setAlerts(open);
const recentReports = (reportRes.data ?? [])
.slice()
.sort((a, b) => b.submitted_at.localeCompare(a.submitted_at));
setReports(recentReports);
setSelectedReportId(current => (
current && recentReports.some(r => r.id === current)
? current
: recentReports[0]?.id ?? null
));
setLoading(false);
})();
return () => { cancelled = true; };
}, [refreshKey]);
const ackAlert = async (id: string) => {
const { error } = await api.rpc("ack_alert", {
p_alert: id,
p_note: "Acknowledged from owner overview",
});
if (error) {
toast({ title: "Could not acknowledge", description: error.message, variant: "destructive" });
return;
}
setRefreshKey(k => k + 1);
};
const shopName = (id: string) =>
shops.find(s => s.shop_id === id)?.shop_name
?? user?.shops.find(s => s.shop_id === id)?.shop_name
?? id.slice(0, 8);
const latestReportForShop = (shopId: string) =>
reports.find(r => r.shop_id === shopId) ?? null;
const selectedReport = reports.find(r => r.id === selectedReportId) ?? null;
const submitEndOfDay = async (shopId: string) => {
setSubmittingShopId(shopId);
const { error } = await api.rpc("submit_end_of_day", {
p_shop: shopId,
p_note: "Submitted from owner overview",
});
if (error) {
toast({ title: "Could not submit end of day", description: error.message, variant: "destructive" });
setSubmittingShopId(null);
return;
}
toast({ title: "End of day submitted", description: `${shopName(shopId)} counters reset for the next day.` });
setSubmittingShopId(null);
setRefreshKey(k => k + 1);
};
const reopenLatestEndOfDayForTesting = async (shopId: string) => {
setSubmittingShopId(shopId);
const { error } = await api.post("/admin/dev/end_of_day/reopen_latest", {
shop_id: shopId,
});
if (error) {
toast({ title: "Could not reset local EOD", description: error.message, variant: "destructive" });
setSubmittingShopId(null);
return;
}
toast({
title: "Local EOD reset",
description: `${shopName(shopId)} latest end-of-day report was moved back one day for another test pass.`,
});
setSubmittingShopId(null);
setRefreshKey(k => k + 1);
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>End of day</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-slate-500">
Owner-only submit. This writes an append-only end-of-day report, captures the activity log for the current period, and resets dashboard counters from the submission time forward.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{shops.map(s => {
const latest = latestReportForShop(s.shop_id);
return (
<div key={s.shop_id} className="border rounded-lg p-4 bg-white space-y-3">
<div>
<div className="font-semibold text-slate-800">{s.shop_name}</div>
<div className="text-xs text-slate-500">
{latest
? `Last end of day: ${new Date(latest.submitted_at).toLocaleString()}`
: "No end-of-day report submitted yet."}
</div>
</div>
<div className="text-xs text-slate-600 space-y-1">
<div>Open shifts: <span className="font-mono">{s.open_shifts}</span></div>
<div>Current gross USD: <span className="font-mono">{fmtNum(s.today_gross_usd, 2)}</span></div>
<div>Current gross LBP: <span className="font-mono">{fmtNum(s.today_gross_lbp, 0)}</span></div>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => submitEndOfDay(s.shop_id)}
disabled={loading || submittingShopId === s.shop_id}
className="bg-slate-900 hover:bg-slate-800 text-white"
>
{submittingShopId === s.shop_id ? "Submitting…" : "Submit end of day"}
</Button>
{isLocalDev && (
<Button
variant="outline"
onClick={() => reopenLatestEndOfDayForTesting(s.shop_id)}
disabled={loading || submittingShopId === s.shop_id}
>
Reset local EOD test
</Button>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Per-shop KPIs */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Current day across your shops</CardTitle>
<Button variant="outline" size="sm" onClick={() => setRefreshKey(k => k + 1)} disabled={loading}>
{loading ? "Loading…" : "Refresh"}
</Button>
</CardHeader>
<CardContent>
<div className="text-xs text-slate-500 mb-4">
Gross counters reset from the most recent end-of-day submission for each shop. If no report exists yet, they fall back to the current Beirut business day.
</div>
{shops.length === 0 ? (
<div className="text-sm text-slate-500">No shops visible.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{shops.map(s => (
<div key={s.shop_id} className="border rounded-lg p-4 bg-white">
<div className="text-sm font-semibold text-slate-800 mb-2 truncate">{s.shop_name}</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex flex-col"><span className="text-slate-500">Open shifts</span><span className="text-base font-mono">{s.open_shifts}</span></div>
<div className="flex flex-col">
<span className="text-slate-500">Open alerts</span>
<span className={`text-base font-mono ${Number(s.critical_alerts) > 0 ? "text-rose-700" : Number(s.open_alerts) > 0 ? "text-amber-700" : ""}`}>
{s.open_alerts}{Number(s.critical_alerts) > 0 ? ` (${s.critical_alerts}!)` : ""}
</span>
</div>
<div className="flex flex-col"><span className="text-slate-500">Recon exceptions</span><span className="text-base font-mono">{s.open_recon_exceptions}</span></div>
<div className="flex flex-col"><span className="text-slate-500">Today gross USD</span><span className="text-base font-mono">{fmtNum(s.today_gross_usd, 2)}</span></div>
<div className="flex flex-col col-span-2"><span className="text-slate-500">Today gross LBP</span><span className="text-base font-mono">{fmtNum(s.today_gross_lbp, 0)}</span></div>
<div className="flex flex-col col-span-2"><span className="text-slate-500">Current period started</span><span className="text-xs font-mono">{new Date(s.period_started_at).toLocaleString()}</span></div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Open alerts */}
<Card>
<CardHeader>
<CardTitle>Open alerts ({alerts.length})</CardTitle>
</CardHeader>
<CardContent>
{alerts.length === 0 ? (
<div className="text-sm text-emerald-700">No open alerts. Drawers are clean.</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Shop</TableHead>
<TableHead>Kind</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Detail</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alerts.map(a => (
<TableRow key={a.id}>
<TableCell className="whitespace-nowrap text-xs">{new Date(a.created_at).toLocaleString()}</TableCell>
<TableCell className="text-sm">{shopName(a.shop_id)}</TableCell>
<TableCell className="text-sm font-mono">{a.kind}</TableCell>
<TableCell>
<span className={
a.severity === "critical" ? "text-rose-700 font-semibold"
: a.severity === "warn" ? "text-amber-700"
: "text-slate-600"
}>{a.severity}</span>
</TableCell>
<TableCell className="text-xs font-mono max-w-md truncate" title={JSON.stringify(a.payload)}>
{JSON.stringify(a.payload)}
</TableCell>
<TableCell className="text-right">
<Button size="sm" variant="outline" onClick={() => ackAlert(a.id)}>Acknowledge</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Recently closed shifts with variance */}
<Card>
<CardHeader>
<CardTitle>Recently closed shifts variance</CardTitle>
</CardHeader>
<CardContent>
{zRows.length === 0 ? (
<div className="text-sm text-slate-500">No closed shifts yet.</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Closed</TableHead>
<TableHead>Shop</TableHead>
<TableHead className="text-right">Expected USD</TableHead>
<TableHead className="text-right">Counted USD</TableHead>
<TableHead className="text-right">Δ USD</TableHead>
<TableHead className="text-right">Expected LBP</TableHead>
<TableHead className="text-right">Counted LBP</TableHead>
<TableHead className="text-right">Δ LBP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{zRows.map(r => (
<TableRow key={r.shift_id}>
<TableCell className="whitespace-nowrap text-xs">{r.closed_at ? new Date(r.closed_at).toLocaleString() : "—"}</TableCell>
<TableCell className="text-sm">{shopName(r.shop_id)}</TableCell>
<TableCell className="text-right font-mono">{fmtNum(r.expected_close_usd, 2)}</TableCell>
<TableCell className="text-right font-mono">{fmtNum(r.declared_close_usd, 2)}</TableCell>
<TableCell className={`text-right font-mono ${varianceTone(r.variance_usd)}`}>{fmtNum(r.variance_usd, 2)}</TableCell>
<TableCell className="text-right font-mono">{fmtNum(r.expected_close_lbp, 0)}</TableCell>
<TableCell className="text-right font-mono">{fmtNum(r.declared_close_lbp, 0)}</TableCell>
<TableCell className={`text-right font-mono ${varianceTone(r.variance_lbp)}`}>{fmtNum(r.variance_lbp, 0)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* 30-day cashier scorecard */}
<Card>
<CardHeader>
<CardTitle>Cashier scorecard (last 30 days)</CardTitle>
</CardHeader>
<CardContent>
{scores.length === 0 ? (
<div className="text-sm text-slate-500">No closed shifts in the last 30 days.</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Cashier</TableHead>
<TableHead>Shop</TableHead>
<TableHead className="text-right">Shifts</TableHead>
<TableHead className="text-right">Σ Δ USD</TableHead>
<TableHead className="text-right">Σ Δ LBP</TableHead>
<TableHead className="text-right">Voids</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scores
.slice()
.sort((a, b) => Math.abs(Number(b.total_var_usd ?? 0)) - Math.abs(Number(a.total_var_usd ?? 0)))
.map(s => (
<TableRow key={`${s.cashier_id}:${s.shop_id}`}>
<TableCell className="font-mono text-xs">{s.cashier_id.slice(0, 8)}</TableCell>
<TableCell className="text-sm">{shopName(s.shop_id)}</TableCell>
<TableCell className="text-right font-mono">{s.shifts_30d}</TableCell>
<TableCell className={`text-right font-mono ${varianceTone(s.total_var_usd)}`}>{fmtNum(s.total_var_usd, 2)}</TableCell>
<TableCell className={`text-right font-mono ${varianceTone(s.total_var_lbp)}`}>{fmtNum(s.total_var_lbp, 0)}</TableCell>
<TableCell className="text-right font-mono">{s.voids_30d}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent end-of-day reports</CardTitle>
</CardHeader>
<CardContent>
{reports.length === 0 ? (
<div className="text-sm text-slate-500">No end-of-day reports yet.</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Business date</TableHead>
<TableHead>Shop</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>By</TableHead>
<TableHead className="text-right">Txns</TableHead>
<TableHead className="text-right">Gross USD</TableHead>
<TableHead className="text-right">Gross LBP</TableHead>
<TableHead className="text-right">Activities</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map(r => (
<TableRow
key={r.id}
className={selectedReportId === r.id ? "bg-slate-50" : "cursor-pointer"}
onClick={() => setSelectedReportId(r.id)}
>
<TableCell>{fmtBusinessDate(r.business_date)}</TableCell>
<TableCell>{shopName(r.shop_id)}</TableCell>
<TableCell className="whitespace-nowrap text-xs">{new Date(r.submitted_at).toLocaleString()}</TableCell>
<TableCell>{r.submitted_by_name}</TableCell>
<TableCell className="text-right font-mono">{r.completed_txn_count}</TableCell>
<TableCell className="text-right font-mono">{fmtNum(r.gross_usd, 2)}</TableCell>
<TableCell className="text-right font-mono">{fmtNum(r.gross_lbp, 0)}</TableCell>
<TableCell className="text-right font-mono">{r.activity_count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Activity log for selected report</CardTitle>
</CardHeader>
<CardContent>
{!selectedReport ? (
<div className="text-sm text-slate-500">Select an end-of-day report to inspect the activity log.</div>
) : selectedReport.activity_log.length === 0 ? (
<div className="text-sm text-slate-500">No activity events were captured in that reporting period.</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Event</TableHead>
<TableHead>Metadata</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedReport.activity_log.map((entry, index) => (
<TableRow key={`${selectedReport.id}:${index}`}>
<TableCell className="whitespace-nowrap text-xs">{new Date(entry.occurred_at).toLocaleString()}</TableCell>
<TableCell className="font-mono text-xs">{entry.event_type}</TableCell>
<TableCell className="text-xs font-mono max-w-md truncate" title={JSON.stringify(entry.metadata)}>
{JSON.stringify(entry.metadata)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
};
export default OwnerOverview;
+590
View File
@@ -0,0 +1,590 @@
import React, { useCallback, useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/integrations/supabase/client";
import { FxSwapDialog, SelfDealOverrideDialog } from "@/components/CashierTools";
interface MyTill {
till_id: string;
shop_id: string;
name: string;
is_active: boolean;
}
interface OpenShift {
shift_id: string;
till_id: string;
opened_at: string;
status: string;
opening_usd: number;
opening_lbp: number;
declared_at?: string | null;
declared_close_usd?: number | null;
declared_close_lbp?: number | null;
}
interface LiveDrawerSummary {
shift_id: string;
expected_usd: number;
expected_lbp: number;
customer_in_usd: number;
customer_in_lbp: number;
payout_out_usd: number;
payout_out_lbp: number;
dropped_to_safe_usd: number;
dropped_to_safe_lbp: number;
fx_net_usd: number;
fx_net_lbp: number;
txn_count: number;
last_txn_at: string | null;
}
function formatAmount(value: number, decimals: number) {
return value.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
/**
* ShiftControl
*
* Open / declare-close / finalize-close. The cashier MUST have an open
* shift before TransactionEntry can record anything (the RPC checks).
* For the owner, this also serves as a one-stop view of every till.
*/
export const ShiftControl: React.FC = () => {
const { user } = useAuth();
const { toast } = useToast();
const [shopId, setShopId] = useState<string>("");
const [tills, setTills] = useState<MyTill[]>([]);
const [tillId, setTillId] = useState<string>("");
const [shopUsers, setShopUsers] = useState<{user_id: string, full_name: string, role: string}[]>([]);
const [assignedUserId, setAssignedUserId] = useState<string>("");
const [openShift, setOpenShift] = useState<OpenShift | null>(null);
const [openingUsd, setOpeningUsd] = useState("");
const [openingLbp, setOpeningLbp] = useState("");
const [declaredUsd, setDeclaredUsd] = useState("");
const [declaredLbp, setDeclaredLbp] = useState("");
const [dropUsd, setDropUsd] = useState("");
const [dropLbp, setDropLbp] = useState("");
const [fxOpen, setFxOpen] = useState(false);
const [overrideOpen, setOverrideOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [drawerSummary, setDrawerSummary] = useState<LiveDrawerSummary | null>(null);
// Result of the most recent finalize_close, used to display
// Expected / Declared / Variance to the cashier and manager.
const [closeResult, setCloseResult] = useState<{
expected_usd: number;
expected_lbp: number;
declared_usd: number;
declared_lbp: number;
variance_usd: number;
variance_lbp: number;
} | null>(null);
const shops = user?.shops ?? [];
const selectedShopRole = shops.find((shop) => shop.shop_id === shopId)?.role;
const canAssignShift = selectedShopRole === "owner" || selectedShopRole === "manager";
useEffect(() => {
if (shops.length === 1) setShopId(shops[0].shop_id);
}, [shops]);
// Load tills for the chosen shop
useEffect(() => {
if (!shopId) { setShopUsers([]); setAssignedUserId(""); return; }
let cancelled = false;
(async () => {
const { data, error } = await supabase
.rpc('get_shop_users', { p_shop_id: shopId });
if (cancelled) return;
if (error) {
console.error("Could not load shop users:", error);
return;
}
const formattedUsers = (data || []).map((row: any) => ({
user_id: row.user_id,
full_name: row.full_name || 'Unknown',
role: row.role
}));
setShopUsers(formattedUsers);
// Auto-assign to current user if they are in the list
if (user && formattedUsers.some(u => u.user_id === user.id)) {
setAssignedUserId(user.id);
} else if (formattedUsers.length > 0) {
setAssignedUserId(formattedUsers[0].user_id);
}
})();
return () => { cancelled = true; };
}, [shopId, user]);
useEffect(() => {
if (!shopId) { setTills([]); setTillId(""); return; }
let cancelled = false;
(async () => {
const { data, error } = await supabase
.from("v_my_tills")
.select("till_id, shop_id, name, is_active")
.eq("shop_id", shopId);
if (cancelled) return;
if (error) {
toast({ title: "Could not load tills", description: error.message,
variant: "destructive" });
return;
}
setTills((data ?? []) as MyTill[]);
if (data && data.length === 1) setTillId(data[0].till_id);
})();
return () => { cancelled = true; };
}, [shopId, toast]);
// Refresh open-shift status for caller
const refreshShift = useCallback(async () => {
if (!shopId) { setOpenShift(null); return; }
const { data } = await supabase.rpc("my_active_shift", { p_shop: shopId });
const row = Array.isArray(data)
? (data[0] as OpenShift | undefined) ?? null
: ((data as OpenShift | null) ?? null);
setOpenShift(row);
if (row) setTillId(row.till_id);
}, [shopId]);
const refreshDrawer = useCallback(async (shiftId?: string) => {
if (!shiftId) {
setDrawerSummary(null);
return;
}
const { data, error } = await supabase.rpc("live_drawer", { p_shift_id: shiftId });
if (error) {
toast({
title: "Could not load live drawer",
description: error.message,
variant: "destructive",
});
return;
}
const row = Array.isArray(data)
? (data[0] as LiveDrawerSummary | undefined) ?? null
: ((data as LiveDrawerSummary | null) ?? null);
setDrawerSummary(row);
}, [toast]);
useEffect(() => { refreshShift(); }, [refreshShift]);
useEffect(() => {
if (!openShift) {
setDrawerSummary(null);
return;
}
refreshDrawer(openShift.shift_id);
if (openShift.status !== "open") return;
const timer = window.setInterval(() => {
refreshDrawer(openShift.shift_id);
}, 15000);
return () => window.clearInterval(timer);
}, [openShift, refreshDrawer]);
const handleOpen = async () => {
if (!tillId) {
toast({ title: "Pick a till", variant: "destructive" });
return;
}
setBusy(true);
try {
const assignedUserParam = canAssignShift && assignedUserId && assignedUserId !== user?.id
? assignedUserId
: undefined;
const { error } = await supabase.rpc("open_shift", {
p_till_id: tillId,
p_opening_usd: Number.parseFloat(openingUsd) || 0,
p_opening_lbp: Number.parseFloat(openingLbp) || 0,
p_assigned_user_id: assignedUserParam,
});
if (error) throw error;
toast({ title: "Shift opened" });
setOpeningUsd(""); setOpeningLbp("");
await refreshShift();
} catch (e) {
toast({ title: "Could not open shift",
description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
const handleDeclare = async () => {
if (!openShift) return;
if (!declaredUsd.trim() && !declaredLbp.trim()) {
toast({
title: "Count required",
description: "Enter the counted USD and/or LBP before declaring close.",
variant: "destructive",
});
return;
}
setBusy(true);
try {
const { error } = await supabase.rpc("declare_close", {
p_shift_id: openShift.shift_id,
p_declared_close_usd: Number.parseFloat(declaredUsd) || 0,
p_declared_close_lbp: Number.parseFloat(declaredLbp) || 0,
});
if (error) throw error;
toast({ title: "Declared. Finalize when ready." });
await refreshDrawer(openShift.shift_id);
await refreshShift();
} catch (e) {
toast({ title: "Could not declare close",
description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
const handleDrop = async () => {
if (!openShift) return;
if (!dropUsd && !dropLbp) return;
setBusy(true);
try {
const { error } = await supabase.rpc("record_cash_drop", {
p_shift_id: openShift.shift_id,
p_drop_usd: Number.parseFloat(dropUsd) || 0,
p_drop_lbp: Number.parseFloat(dropLbp) || 0,
p_notes: "Mid-day safe drop",
});
if (error) throw error;
toast({ title: "Safe drop recorded successfully" });
setDropUsd(""); setDropLbp("");
await refreshDrawer(openShift.shift_id);
await refreshShift();
} catch (e) {
toast({ title: "Could not record drop",
description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
const handleFinalize = async () => {
if (!openShift) return;
setBusy(true);
const declared_usd = Number(declaredUsd) || 0;
const declared_lbp = Number(declaredLbp) || 0;
try {
const { data, error } = await supabase.rpc("finalize_close", {
p_shift_id: openShift.shift_id,
});
if (error) throw error;
// finalize_close returns a single row { expected_usd, expected_lbp, variance_usd, variance_lbp }
const row = (Array.isArray(data) ? data[0] : data) as
| { expected_usd?: number; expected_lbp?: number; variance_usd?: number; variance_lbp?: number }
| null;
if (row) {
setCloseResult({
expected_usd: Number(row.expected_usd ?? 0),
expected_lbp: Number(row.expected_lbp ?? 0),
declared_usd,
declared_lbp,
variance_usd: Number(row.variance_usd ?? 0),
variance_lbp: Number(row.variance_lbp ?? 0),
});
}
toast({ title: "Shift closed" });
setDeclaredUsd(""); setDeclaredLbp("");
setDrawerSummary(null);
await refreshShift();
} catch (e) {
toast({ title: "Could not finalize",
description: (e as Error).message, variant: "destructive" });
} finally { setBusy(false); }
};
return (
<Card className="shadow-sm border-slate-200">
<CardHeader className="border-b bg-slate-50/50 py-4 px-6">
<CardTitle className="text-lg font-semibold text-slate-800">Shift control</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pt-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
<div className="space-y-1.5">
<Label className="text-sm font-medium text-slate-700">Shop</Label>
<Select value={shopId} onValueChange={setShopId}>
<SelectTrigger><SelectValue placeholder="Select shop" /></SelectTrigger>
<SelectContent>
{shops.map((s) => (
<SelectItem key={s.shop_id} value={s.shop_id}>
{s.shop_name} · {s.role}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium text-slate-700">Till</Label>
<Select value={tillId} onValueChange={setTillId}
disabled={!!openShift}>
<SelectTrigger><SelectValue placeholder="Select till" /></SelectTrigger>
<SelectContent>
{tills.map((t) => (
<SelectItem key={t.till_id} value={t.till_id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!openShift && canAssignShift && (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-sm font-medium text-slate-700">Assign shift to employee</Label>
<Select value={assignedUserId} onValueChange={setAssignedUserId}>
<SelectTrigger><SelectValue placeholder="Select user" /></SelectTrigger>
<SelectContent>
{shopUsers.map((u) => (
<SelectItem key={u.user_id} value={u.user_id}>{u.full_name} ({u.role})</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{openShift ? (
<div className="border rounded p-3 space-y-3">
<div className="text-sm">
<span className="font-medium">Open shift:</span> till{" "}
{openShift.till_id.slice(0, 8)} opened{" "}
{new Date(openShift.opened_at).toLocaleString()} · status{" "}
<span className="font-mono">{openShift.status}</span>
<br />
Opening float: USD {openShift.opening_usd} / LBP{" "}
{openShift.opening_lbp}
</div>
{drawerSummary && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4 border-t mt-4">
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-slate-800">Live drawer now</h4>
<span className="text-xs text-slate-500">
{drawerSummary.txn_count} txns
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded border bg-white p-3">
<div className="text-xs uppercase tracking-wider text-slate-500 mb-1">Expected USD</div>
<div className="font-mono text-lg text-slate-900">
{formatAmount(drawerSummary.expected_usd, 2)}
</div>
</div>
<div className="rounded border bg-white p-3">
<div className="text-xs uppercase tracking-wider text-slate-500 mb-1">Expected LBP</div>
<div className="font-mono text-lg text-slate-900">
{formatAmount(drawerSummary.expected_lbp, 0)}
</div>
</div>
</div>
<div className="text-xs text-slate-500">
Last transaction: {drawerSummary.last_txn_at ? new Date(drawerSummary.last_txn_at).toLocaleString() : "No completed transactions yet"}
</div>
</div>
<div className="rounded-lg border border-slate-200 bg-white p-4 space-y-3">
<h4 className="text-sm font-semibold text-slate-800">What moved this drawer</h4>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div className="flex justify-between gap-3">
<span className="text-slate-500">Customer in USD</span>
<span className="font-mono text-emerald-700">{formatAmount(drawerSummary.customer_in_usd, 2)}</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-slate-500">Customer in LBP</span>
<span className="font-mono text-emerald-700">{formatAmount(drawerSummary.customer_in_lbp, 0)}</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-slate-500">Payout out USD</span>
<span className="font-mono text-rose-700">{formatAmount(drawerSummary.payout_out_usd, 2)}</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-slate-500">Payout out LBP</span>
<span className="font-mono text-rose-700">{formatAmount(drawerSummary.payout_out_lbp, 0)}</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-slate-500">Dropped to safe USD</span>
<span className="font-mono text-amber-700">{formatAmount(drawerSummary.dropped_to_safe_usd, 2)}</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-slate-500">Dropped to safe LBP</span>
<span className="font-mono text-amber-700">{formatAmount(drawerSummary.dropped_to_safe_lbp, 0)}</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-slate-500">FX net USD</span>
<span className="font-mono text-slate-700">{formatAmount(drawerSummary.fx_net_usd, 2)}</span>
</div>
<div className="flex justify-between gap-3">
<span className="text-slate-500">FX net LBP</span>
<span className="font-mono text-slate-700">{formatAmount(drawerSummary.fx_net_lbp, 0)}</span>
</div>
</div>
</div>
</div>
)}
{openShift.status === "open" ? (
<>
<div className="pt-4 border-t mt-4">
<h4 className="text-sm font-semibold mb-2">Mid-Day Safe Drop</h4>
<p className="text-xs text-gray-500 mb-3">
Transfer large sums of money out of the drawer and into the shop safe to secure it. This removes it from your end-of-shift expected count.
</p>
<div className="grid grid-cols-3 gap-3 items-end">
<div>
<Label>Drop USD</Label>
<Input type="number" step="0.01" value={dropUsd} placeholder="0.00"
onChange={(e) => setDropUsd(e.target.value)} />
</div>
<div>
<Label>Drop LBP</Label>
<Input type="number" step="1" value={dropLbp} placeholder="0"
onChange={(e) => setDropLbp(e.target.value)} />
</div>
<Button onClick={handleDrop} disabled={busy || (!dropUsd && !dropLbp)} variant="outline" className="border-amber-600 text-amber-700 hover:bg-amber-50">
Record Safe Drop
</Button>
</div>
</div>
<div className="pt-4 border-t mt-4 flex flex-wrap gap-2">
<Button type="button" variant="outline" onClick={() => setFxOpen(true)}
className="border-blue-600 text-blue-700 hover:bg-blue-50">
Cash FX Swap
</Button>
<Button type="button" variant="outline" onClick={() => setOverrideOpen(true)}
className="border-rose-600 text-rose-700 hover:bg-rose-50">
Manager Override (self-deal)
</Button>
</div>
<div className="grid grid-cols-3 gap-3 items-end mt-4">
<div>
<Label>Declared USD</Label>
<Input type="number" step="0.01" value={declaredUsd}
onChange={(e) => setDeclaredUsd(e.target.value)} />
</div>
<div>
<Label>Declared LBP</Label>
<Input type="number" step="1" value={declaredLbp}
onChange={(e) => setDeclaredLbp(e.target.value)} />
</div>
<Button onClick={handleDeclare} disabled={busy}>
Declare close
</Button>
</div>
</>
) : (
<div className="space-y-4 pt-4 border-t mt-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm">
<div className="font-semibold text-amber-900 mb-2">Close declared</div>
<div className="grid grid-cols-2 gap-3 font-mono text-amber-900">
<div>Counted USD: {formatAmount(Number(openShift.declared_close_usd ?? 0), 2)}</div>
<div>Counted LBP: {formatAmount(Number(openShift.declared_close_lbp ?? 0), 0)}</div>
</div>
<p className="mt-2 text-xs text-amber-800">
The drawer count is locked. Finalize to reveal the expected cash and variance.
</p>
</div>
<Button onClick={handleFinalize}
disabled={busy}
className="bg-red-600 hover:bg-red-700 text-white">
Finalize close
</Button>
</div>
)}
</div>
) : closeResult ? (
<div className="border rounded p-4 space-y-3 bg-slate-50">
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold">Last shift close variance summary</h4>
<Button variant="ghost" size="sm" onClick={() => setCloseResult(null)}>Dismiss</Button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
{([
{ label: "USD", expected: closeResult.expected_usd, declared: closeResult.declared_usd, variance: closeResult.variance_usd, decimals: 2 },
{ label: "LBP", expected: closeResult.expected_lbp, declared: closeResult.declared_lbp, variance: closeResult.variance_lbp, decimals: 0 },
] as const).map((r) => {
const fmt = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: r.decimals, maximumFractionDigits: r.decimals });
const tone =
r.variance === 0 ? "text-emerald-700 bg-emerald-50 border-emerald-200"
: r.variance < 0 ? "text-rose-700 bg-rose-50 border-rose-200"
: "text-amber-700 bg-amber-50 border-amber-200";
const label =
r.variance === 0 ? "Match"
: r.variance < 0 ? "SHORT"
: "OVER";
return (
<div key={r.label} className={`border rounded p-3 ${tone}`}>
<div className="text-xs font-semibold uppercase tracking-wider mb-2">{r.label}</div>
<div className="flex justify-between"><span className="text-slate-600">Expected</span><span className="font-mono">{fmt(r.expected)}</span></div>
<div className="flex justify-between"><span className="text-slate-600">Counted</span><span className="font-mono">{fmt(r.declared)}</span></div>
<div className="flex justify-between font-semibold pt-1 border-t mt-1">
<span>Δ {label}</span><span className="font-mono">{fmt(r.variance)}</span>
</div>
</div>
);
})}
</div>
<p className="text-xs text-slate-500">
Negative Δ = drawer is short of what the system expected.
Positive Δ = drawer has more than expected. Anything non-zero
should be investigated before the cashier leaves.
</p>
</div>
) : (
<div className="border border-slate-200 bg-slate-50/40 rounded-lg p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
<div className="space-y-1.5">
<Label className="text-sm font-medium text-slate-700">Opening USD</Label>
<Input type="number" step="0.01" value={openingUsd} placeholder="0.00"
onChange={(e) => setOpeningUsd(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium text-slate-700">Opening LBP</Label>
<Input type="number" step="1" value={openingLbp} placeholder="0"
onChange={(e) => setOpeningLbp(e.target.value)} />
</div>
<Button onClick={handleOpen}
disabled={busy || !tillId}
className="bg-emerald-600 hover:bg-emerald-700 text-white h-10">
Open shift
</Button>
</div>
<p className="text-xs text-slate-500">
Count the drawer cash <em>before</em> opening. The amounts
you declare here become the audit baseline for this shift.
</p>
</div>
)}
</CardContent>
<FxSwapDialog
open={fxOpen}
onClose={() => setFxOpen(false)}
shopId={shopId}
tillId={openShift?.till_id ?? tillId}
onDone={refreshShift}
/>
<SelfDealOverrideDialog
open={overrideOpen}
onClose={() => setOverrideOpen(false)}
shopId={shopId}
/>
</Card>
);
};
+399
View File
@@ -0,0 +1,399 @@
import React, { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api";
import { BadgeDollarSign, Eye, Printer, RotateCcw, Search, XCircle } from "lucide-react";
type TxnStatus = "completed" | "voided";
interface RecentTransactionRow {
id: string;
reference_no: number;
shop_id: string;
till_id: string;
shift_id: string;
service_code: string;
service_name: string;
category: string;
payment_method: string;
gross_usd: string | number;
gross_lbp: string | number;
revenue_usd: string | number;
revenue_lbp: string | number;
external_ref: string | null;
external_ref_provider: string | null;
beneficiary_name: string | null;
beneficiary_phone: string | null;
status: TxnStatus;
occurred_at: string;
user_id: string;
}
interface ReceiptPrintRow {
receipt_id: string;
qr_token: string;
pdf_url: string | null;
}
const fmtUsd = (value: string | number | null | undefined) =>
Number(value ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const fmtLbp = (value: string | number | null | undefined) =>
Number(value ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 });
export const TransactionCenter: React.FC = () => {
const { toast } = useToast();
const [rows, setRows] = useState<RecentTransactionRow[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | TxnStatus>("all");
const [selected, setSelected] = useState<RecentTransactionRow | null>(null);
const [receipt, setReceipt] = useState<ReceiptPrintRow | null>(null);
const [voidTarget, setVoidTarget] = useState<RecentTransactionRow | null>(null);
const [voidReason, setVoidReason] = useState("");
const [managerPin, setManagerPin] = useState("");
const [refundTarget, setRefundTarget] = useState<RecentTransactionRow | null>(null);
const [refundUsd, setRefundUsd] = useState("");
const [refundLbp, setRefundLbp] = useState("");
const [refundReason, setRefundReason] = useState("");
const [refundManagerPin, setRefundManagerPin] = useState("");
const [busyTxnId, setBusyTxnId] = useState<string | null>(null);
const refresh = async () => {
setLoading(true);
const { data, error } = await api.fromView<RecentTransactionRow>("v_my_recent_transactions");
if (error) {
toast({ title: "Could not load transactions", description: error.message, variant: "destructive" });
setLoading(false);
return;
}
setRows((data ?? []).sort((a, b) => b.occurred_at.localeCompare(a.occurred_at)).slice(0, 100));
setLoading(false);
};
useEffect(() => { refresh(); }, []);
const filteredRows = useMemo(() => {
const needle = query.trim().toLowerCase();
return rows.filter((row) => {
if (statusFilter !== "all" && row.status !== statusFilter) return false;
if (!needle) return true;
return [
row.reference_no,
row.service_code,
row.service_name,
row.payment_method,
row.external_ref,
row.beneficiary_name,
row.beneficiary_phone,
row.id,
].some((value) => String(value ?? "").toLowerCase().includes(needle));
});
}, [query, rows, statusFilter]);
const reprintReceipt = async (row: RecentTransactionRow) => {
setBusyTxnId(row.id);
const { data, error } = await api.rpc<ReceiptPrintRow[] | ReceiptPrintRow>("record_receipt_print", {
p_txn_id: row.id,
p_kind: "reprint",
p_device: "transaction-center",
});
setBusyTxnId(null);
if (error) {
toast({ title: "Could not log reprint", description: error.message, variant: "destructive" });
return;
}
const receiptRow = Array.isArray(data) ? data[0] : data;
setSelected(row);
setReceipt(receiptRow ?? null);
toast({ title: "Receipt reprint logged", description: `Receipt #${row.reference_no} has a fresh verification token.` });
};
const voidTransaction = async () => {
if (!voidTarget) return;
setBusyTxnId(voidTarget.id);
const { error } = await api.rpc("void_transaction", {
p_txn_id: voidTarget.id,
p_reason: voidReason,
p_approver_pin: managerPin.trim() || null,
});
setBusyTxnId(null);
if (error) {
toast({ title: "Could not void transaction", description: error.message, variant: "destructive" });
return;
}
toast({ title: "Transaction voided", description: `Receipt #${voidTarget.reference_no} was reversed.` });
setVoidTarget(null);
setVoidReason("");
setManagerPin("");
await refresh();
};
const openRefundDialog = (row: RecentTransactionRow) => {
setRefundTarget(row);
setRefundUsd(Number(row.gross_usd) > 0 ? String(Number(row.gross_usd)) : "");
setRefundLbp(Number(row.gross_lbp) > 0 ? String(Number(row.gross_lbp)) : "");
setRefundReason("");
setRefundManagerPin("");
};
const issueRefund = async () => {
if (!refundTarget) return;
setBusyTxnId(refundTarget.id);
const { data, error } = await api.rpc<string>("issue_refund", {
p_original_txn: refundTarget.id,
p_amount_usd: Number(refundUsd) || 0,
p_amount_lbp: Number(refundLbp) || 0,
p_reason: refundReason,
p_manager_pin: refundManagerPin,
});
if (error) {
setBusyTxnId(null);
toast({ title: "Could not issue refund", description: error.message, variant: "destructive" });
return;
}
if (data) {
await api.rpc<ReceiptPrintRow[] | ReceiptPrintRow>("record_receipt_print", {
p_txn_id: data,
p_kind: "original",
p_device: "transaction-center-refund",
});
}
setBusyTxnId(null);
toast({ title: "Refund issued", description: `Refund recorded against receipt #${refundTarget.reference_no}.` });
setRefundTarget(null);
setRefundUsd("");
setRefundLbp("");
setRefundReason("");
setRefundManagerPin("");
await refresh();
};
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg font-semibold text-slate-800">Transactions</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-[1fr_180px_auto] gap-3">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search receipt, service, reference, beneficiary"
className="pl-9"
/>
</div>
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as "all" | TxnStatus)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="voided">Voided</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={refresh} disabled={loading}>Refresh</Button>
</div>
<div className="rounded-lg border bg-white overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead>Receipt</TableHead>
<TableHead>Service</TableHead>
<TableHead>Amount</TableHead>
<TableHead>External Ref</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-mono">#{row.reference_no}</TableCell>
<TableCell>
<div className="font-medium text-slate-800">{row.service_name}</div>
<div className="text-xs text-slate-500">{row.payment_method}</div>
</TableCell>
<TableCell className="font-mono text-sm">
<div>USD {fmtUsd(row.gross_usd)}</div>
<div>LBP {fmtLbp(row.gross_lbp)}</div>
</TableCell>
<TableCell className="max-w-[180px] truncate">{row.external_ref || "-"}</TableCell>
<TableCell>
<div>{row.beneficiary_name || "-"}</div>
<div className="text-xs text-slate-500">{row.beneficiary_phone || ""}</div>
</TableCell>
<TableCell>
<span className={row.status === "completed" ? "text-emerald-700" : "text-rose-700 font-medium"}>
{row.status}
</span>
</TableCell>
<TableCell>{new Date(row.occurred_at).toLocaleString()}</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => { setSelected(row); setReceipt(null); }}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => reprintReceipt(row)} disabled={busyTxnId === row.id}>
<Printer className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setVoidTarget(row)}
disabled={row.status !== "completed" || busyTxnId === row.id}
className="text-rose-700 border-rose-200 hover:text-rose-800"
>
<XCircle className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => openRefundDialog(row)}
disabled={row.status !== "completed" || row.service_code === "REFUND" || busyTxnId === row.id}
className="text-amber-700 border-amber-200 hover:text-amber-800"
>
<BadgeDollarSign className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{!filteredRows.length && (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-slate-500">
{loading ? "Loading transactions..." : "No matching transactions."}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="w-[min(96vw,620px)] sm:max-w-[620px] bg-white">
<DialogHeader>
<DialogTitle>Receipt #{selected?.reference_no}</DialogTitle>
<DialogDescription>{selected?.service_name} transaction details and verification data.</DialogDescription>
</DialogHeader>
{selected && (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-3">
<div><div className="text-slate-500">Transaction ID</div><div className="font-mono break-all">{selected.id}</div></div>
<div><div className="text-slate-500">Status</div><div>{selected.status}</div></div>
<div><div className="text-slate-500">External reference</div><div>{selected.external_ref || "Not captured"}</div></div>
<div><div className="text-slate-500">Occurred</div><div>{new Date(selected.occurred_at).toLocaleString()}</div></div>
<div><div className="text-slate-500">USD</div><div className="font-mono">{fmtUsd(selected.gross_usd)}</div></div>
<div><div className="text-slate-500">LBP</div><div className="font-mono">{fmtLbp(selected.gross_lbp)}</div></div>
</div>
{receipt && (
<div className="rounded-lg border bg-slate-50 p-3 space-y-1">
<div className="font-medium text-slate-800">Reprint logged</div>
<div>Receipt log ID: <span className="font-mono">{receipt.receipt_id}</span></div>
<div className="break-all">QR token: <span className="font-mono">{receipt.qr_token}</span></div>
<div>PDF: <span className="font-mono">{receipt.pdf_url ?? "Not generated yet"}</span></div>
</div>
)}
</div>
)}
<DialogFooter>
{selected && <Button variant="outline" onClick={() => reprintReceipt(selected)}><Printer className="h-4 w-4 mr-2" />Log reprint</Button>}
<Button onClick={() => setSelected(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!voidTarget} onOpenChange={(open) => !open && setVoidTarget(null)}>
<DialogContent className="w-[min(96vw,520px)] sm:max-w-[520px] bg-white">
<DialogHeader>
<DialogTitle>Void receipt #{voidTarget?.reference_no}</DialogTitle>
<DialogDescription>
Voiding reverses cash, float, stock, and voucher movements. Manager PIN is required after the self-void window or for another cashier's transaction.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Reason</Label>
<Textarea value={voidReason} onChange={(event) => setVoidReason(event.target.value)} placeholder="At least 5 characters" />
</div>
<div>
<Label>Manager PIN when required</Label>
<Input type="password" value={managerPin} onChange={(event) => setManagerPin(event.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setVoidTarget(null)}>Cancel</Button>
<Button onClick={voidTransaction} disabled={!voidReason.trim() || busyTxnId === voidTarget?.id} className="bg-rose-700 hover:bg-rose-800 text-white">
<RotateCcw className="h-4 w-4 mr-2" />Void and reverse
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!refundTarget} onOpenChange={(open) => !open && setRefundTarget(null)}>
<DialogContent className="w-[min(96vw,540px)] sm:max-w-[540px] bg-white">
<DialogHeader>
<DialogTitle>Refund receipt #{refundTarget?.reference_no}</DialogTitle>
<DialogDescription>
Refunds are linked to the original transaction and require manager role, manager PIN, and an open manager shift in the same shop.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label>Refund USD</Label>
<Input type="number" step="0.01" min="0" value={refundUsd} onChange={(event) => setRefundUsd(event.target.value)} />
</div>
<div>
<Label>Refund LBP</Label>
<Input type="number" step="1" min="0" value={refundLbp} onChange={(event) => setRefundLbp(event.target.value)} />
</div>
</div>
<div className="text-xs text-slate-500">
Original amount: USD {fmtUsd(refundTarget?.gross_usd)} / LBP {fmtLbp(refundTarget?.gross_lbp)}
</div>
<div>
<Label>Reason</Label>
<Textarea value={refundReason} onChange={(event) => setRefundReason(event.target.value)} placeholder="At least 5 characters" />
</div>
<div>
<Label>Manager PIN</Label>
<Input type="password" value={refundManagerPin} onChange={(event) => setRefundManagerPin(event.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRefundTarget(null)}>Cancel</Button>
<Button
onClick={issueRefund}
disabled={
!refundReason.trim()
|| !refundManagerPin
|| ((Number(refundUsd) || 0) <= 0 && (Number(refundLbp) || 0) <= 0)
|| busyTxnId === refundTarget?.id
}
className="bg-amber-700 hover:bg-amber-800 text-white"
>
<BadgeDollarSign className="h-4 w-4 mr-2" />Issue refund
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
File diff suppressed because it is too large Load Diff
+262
View File
@@ -0,0 +1,262 @@
import { useEffect, useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
import { api } from "@/lib/api";
import { Trash2 } from "lucide-react";
type UserRole = "admin" | "owner" | "employee";
interface AdminUser {
id: string;
email: string;
is_active: boolean;
full_name: string | null;
is_system_admin: boolean;
shop_role: "owner" | "manager" | "cashier" | "auditor";
emp_id: string | null;
department: string | null;
}
interface Shop {
shop_id: string;
name: string;
}
function displayRole(user: AdminUser): UserRole {
// ensure we handle cases where it comes in as string "true" somehow, though API should send boolean
if (user.is_system_admin === true || String(user.is_system_admin) === "true") return "admin";
if (user.shop_role === "owner") return "owner";
return "employee";
}
export const UserManagement = () => {
const { toast } = useToast();
const { employees, refreshData } = useSupabaseEmployeeData();
const [users, setUsers] = useState<AdminUser[]>([]);
const [shops, setShops] = useState<Shop[]>([]);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [empId, setEmpId] = useState("");
const [department, setDepartment] = useState("Collections");
const [role, setRole] = useState<UserRole>("employee");
const [shopId, setShopId] = useState("");
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
const { data: userData, error: userError } = await api.get<AdminUser[]>("/admin/users");
if (userError) {
toast({ title: "Could not load users", description: userError.message, variant: "destructive" });
return;
}
setUsers(userData ?? []);
const { data: shopsData } = await api.fromView<Shop>("v_my_shops");
setShops(shopsData ?? []);
if (shopsData && shopsData.length > 0 && !shopId) {
setShopId(shopsData[0].shop_id);
}
}, [toast, shopId]);
useEffect(() => { refresh(); }, [refresh]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
try {
const finalEmpId =
role === "employee"
? empId.trim() || `EMP${String(employees.length + 1).padStart(3, "0")}`
: undefined;
const finalDep = role === "employee" ? department : undefined;
const finalShopId = role !== "admin" ? shopId : undefined;
const { error } = await api.post<AdminUser>("/admin/users", {
email,
password,
name,
role,
department: finalDep,
empId: finalEmpId,
shopId: finalShopId
});
if (error) {
toast({ title: "Could not create user", description: error.message, variant: "destructive" });
return;
}
toast({ title: "User created", description: `${name} (${email})` });
setName("");
setEmail("");
setPassword("");
setEmpId("");
setDepartment("Collections");
setRole("employee");
await Promise.all([refresh(), refreshData()]);
} finally {
setBusy(false);
}
};
const handleDelete = async (id: string) => {
const { error } = await api.delete(`/admin/users/${id}`);
if (error) {
toast({
title: "Could not delete user",
description: error.message,
variant: "destructive",
});
return;
}
toast({ title: "User deleted" });
refresh();
};
return (
<div className="space-y-6">
<div className="bg-white rounded-lg shadow-sm border p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">Create User</h2>
<form
onSubmit={handleCreate}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
<Input
placeholder="Full name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<Input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
type="password"
placeholder="Password (min 6)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
className="h-10 px-3 border border-gray-300 rounded-md bg-white"
>
<option value="employee">Employee</option>
<option value="owner">Owner</option>
<option value="admin">Admin</option>
</select>
{role !== "admin" && shops.length > 0 && (
<select
value={shopId}
onChange={(e) => setShopId(e.target.value)}
className="h-10 px-3 border border-gray-300 rounded-md bg-white"
required
>
<option value="" disabled>Select Shop</option>
{shops.map(s => (
<option key={s.shop_id} value={s.shop_id}>{s.name}</option>
))}
</select>
)}
{role === "employee" && (
<>
<Input
placeholder="Department"
value={department}
onChange={(e) => setDepartment(e.target.value)}
/>
<Input
placeholder="Employee ID (optional)"
value={empId}
onChange={(e) => setEmpId(e.target.value)}
/>
</>
)}
<Button
type="submit"
disabled={busy}
className="bg-orange-500 hover:bg-orange-600 text-white"
>
{busy ? "Adding..." : "Add User"}
</Button>
</form>
</div>
<div className="bg-white rounded-lg shadow-sm border p-6">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
Users ({users.length})
</h2>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Department</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => {
const r = displayRole(u);
return (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.full_name ?? "-"}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>{u.department ?? "-"}</TableCell>
<TableCell>
<span
className={
r === "admin"
? "px-2 py-1 text-xs rounded bg-purple-100 text-purple-700"
: r === "owner"
? "px-2 py-1 text-xs rounded bg-emerald-100 text-emerald-700"
: "px-2 py-1 text-xs rounded bg-gray-100 text-gray-700"
}
>
{r}
</span>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(u.id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-gray-500">
No users yet
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
-56
View File
@@ -1,56 +0,0 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
-139
View File
@@ -1,139 +0,0 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
-59
View File
@@ -1,59 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
-5
View File
@@ -1,5 +0,0 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }
-48
View File
@@ -1,48 +0,0 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
-36
View File
@@ -1,36 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
-115
View File
@@ -1,115 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
-260
View File
@@ -1,260 +0,0 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
-363
View File
@@ -1,363 +0,0 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
-28
View File
@@ -1,28 +0,0 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
-9
View File
@@ -1,9 +0,0 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
-153
View File
@@ -1,153 +0,0 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
-198
View File
@@ -1,198 +0,0 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
-116
View File
@@ -1,116 +0,0 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
-198
View File
@@ -1,198 +0,0 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
-176
View File
@@ -1,176 +0,0 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
-27
View File
@@ -1,27 +0,0 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }
-69
View File
@@ -1,69 +0,0 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
-234
View File
@@ -1,234 +0,0 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}
-128
View File
@@ -1,128 +0,0 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}
-117
View File
@@ -1,117 +0,0 @@
import * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
-26
View File
@@ -1,26 +0,0 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
-42
View File
@@ -1,42 +0,0 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }
-43
View File
@@ -1,43 +0,0 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
-46
View File
@@ -1,46 +0,0 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
-29
View File
@@ -1,29 +0,0 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
-131
View File
@@ -1,131 +0,0 @@
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { }
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet, SheetClose,
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
}
-761
View File
@@ -1,761 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContext | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
)
SidebarProvider.displayName = "SidebarProvider"
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
className
)}
ref={ref}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
)
}
)
Sidebar.displayName = "Sidebar"
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
))
SidebarMenu.displayName = "SidebarMenu"
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
ref={ref}
data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 flex-1 max-w-[--skeleton-width]"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
-15
View File
@@ -1,15 +0,0 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
-26
View File
@@ -1,26 +0,0 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
-27
View File
@@ -1,27 +0,0 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
-59
View File
@@ -1,59 +0,0 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }
-43
View File
@@ -1,43 +0,0 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3",
sm: "h-9 px-2.5",
lg: "h-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }
-19
View File
@@ -1,19 +0,0 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+86 -30
View File
@@ -1,51 +1,107 @@
import { useEffect, useState } from "react";
import { api, type ApiSession } from "@/lib/api";
import { useState, useEffect } from 'react';
import { User, Session } from '@supabase/supabase-js';
import { supabase } from '@/integrations/supabase/client';
export type UiRole = "admin" | "owner" | "employee";
export interface UiShopRole {
shop_id: string;
shop_name: string;
role: "owner" | "manager" | "cashier" | "auditor";
}
export interface UiUser {
id: string;
email: string;
name: string;
role: UiRole;
empId?: string | null;
shops: UiShopRole[];
isSystemAdmin: boolean;
isOwnerAnywhere: boolean;
}
interface MeRow {
user_id: string;
full_name: string;
is_active: boolean;
is_system_admin?: boolean;
is_owner_anywhere: boolean;
emp_id?: string | null;
shops: UiShopRole[] | null;
}
async function loadMe(session: ApiSession): Promise<UiUser> {
const { data, error } = await api.rpc<MeRow[] | MeRow>("me");
if (error) {
return {
id: session.user.id,
email: session.user.email,
name: session.user.full_name ?? session.user.email,
role: "employee",
empId: null,
shops: [],
isSystemAdmin: false,
isOwnerAnywhere: false,
};
}
const row = (Array.isArray(data) ? data[0] : data) as MeRow | undefined;
const shops = (row?.shops ?? []) as UiShopRole[];
const isSystemAdmin = !!row?.is_system_admin;
const isOwner = !!row?.is_owner_anywhere;
return {
id: session.user.id,
email: session.user.email,
name: row?.full_name?.trim() || session.user.full_name || session.user.email,
role: isSystemAdmin ? "admin" : isOwner ? "owner" : "employee",
empId: row?.emp_id ?? null,
shops,
isSystemAdmin,
isOwnerAnywhere: isOwner,
};
}
export const useAuth = () => {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [user, setUser] = useState<UiUser | null>(null);
const [session, setSession] = useState<ApiSession | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Set up auth state listener
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
}
);
let cancelled = false;
// Check for existing session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
const apply = async (s: ApiSession | null) => {
if (cancelled) return;
setSession(s);
if (!s) { setUser(null); return; }
setUser(await loadMe(s));
};
const { data: { session: existing } } = api.auth.getSession();
apply(existing).finally(() => { if (!cancelled) setLoading(false); });
const { data: sub } = api.auth.onAuthStateChange((_event, s) => {
apply(s);
});
return () => subscription.unsubscribe();
return () => {
cancelled = true;
sub.subscription.unsubscribe();
};
}, []);
const signIn = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
const { data, error } = await api.auth.signInWithPassword({
email: email.trim(),
password,
});
return { data, error };
return { data: { session: data.session }, error };
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
const { error } = await api.auth.signOut();
setSession(null);
setUser(null);
return { error };
};
return {
user,
session,
loading,
signIn,
signOut,
};
return { user, session, loading, signIn, signOut };
};
+41 -131
View File
@@ -1,5 +1,10 @@
import { useState, useEffect } from 'react';
/**
* Legacy adapter over `useSupabaseEmployeeData`. Exposes the older shape
* (`{ date, collection, deposit }`) consumed by EmployeePaymentReport so
* we don't have to change that component. Backed by the API; no mocks.
*/
import { useMemo } from "react";
import { useSupabaseEmployeeData } from "./useSupabaseEmployeeData";
export interface Employee {
id: string;
@@ -26,150 +31,55 @@ export interface EmployeeSummary {
lastTransactionDate: string | null;
}
const STORAGE_KEY = 'employeeCollectionData';
// Sample employee data
const initialEmployees: Employee[] = [
{
id: 'EMP001',
name: 'Mayank Sharma',
email: 'mayank.sharma@company.com',
department: 'Collections'
},
{
id: 'EMP002',
name: 'Priya Patel',
email: 'priya.patel@company.com',
department: 'Collections'
},
{
id: 'EMP003',
name: 'Rajesh Kumar',
email: 'rajesh.kumar@company.com',
department: 'Collections'
},
{
id: 'EMP004',
name: 'Anjali Singh',
email: 'anjali.singh@company.com',
department: 'Collections'
},
{
id: 'EMP005',
name: 'Vikram Gupta',
email: 'vikram.gupta@company.com',
department: 'Collections'
}
];
// Sample transaction data for demonstration
const initialTransactions: Record<string, Transaction[]> = {
'EMP001': [
{ date: '2025-03-26', collection: 10000, deposit: 0 },
{ date: '2025-03-27', collection: 20000, deposit: 0 },
{ date: '2025-03-28', collection: 0, deposit: 5000 },
{ date: '2025-03-29', collection: 0, deposit: 7000 },
{ date: '2025-03-30', collection: 0, deposit: 8000 },
{ date: '2025-03-31', collection: 0, deposit: 15000 }
],
'EMP002': [
{ date: '2025-03-25', collection: 15000, deposit: 0 },
{ date: '2025-03-26', collection: 12000, deposit: 15000 },
{ date: '2025-03-27', collection: 0, deposit: 12000 }
],
'EMP003': [
{ date: '2025-03-24', collection: 8000, deposit: 0 },
{ date: '2025-03-25', collection: 0, deposit: 8000 }
]
};
export const useEmployeeData = () => {
const [employees] = useState<Employee[]>(initialEmployees);
const [transactions, setTransactions] = useState<Record<string, Transaction[]>>({});
const src = useSupabaseEmployeeData("USD");
// Load data from localStorage on mount
useEffect(() => {
const savedData = localStorage.getItem(STORAGE_KEY);
if (savedData) {
try {
const parsed = JSON.parse(savedData);
setTransactions(parsed);
} catch (error) {
console.error('Error loading saved data:', error);
setTransactions(initialTransactions);
}
} else {
setTransactions(initialTransactions);
}
}, []);
const employees = useMemo<Employee[]>(
() => src.employees.map((e) => ({
id: e.id,
name: e.name,
email: e.email,
department: e.department,
})),
[src.employees],
);
// Save data to localStorage whenever transactions change
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(transactions));
}, [transactions]);
const addTransaction = (employeeId: string, transaction: Omit<Transaction, 'id'>) => {
setTransactions(prev => ({
...prev,
[employeeId]: [...(prev[employeeId] || []), transaction].sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
)
}));
};
const processTransactions = (employeeTransactions: Transaction[]): ProcessedTransaction[] => {
let runningBalance = 0;
return employeeTransactions.map(transaction => {
const difference = transaction.deposit - transaction.collection;
runningBalance += difference;
return {
...transaction,
difference,
runningBalance
};
const addTransaction = async (
employeeId: string,
transaction: Transaction,
) => {
await src.addTransaction(employeeId, {
transaction_date: transaction.date,
collection_amount: transaction.collection,
deposit_amount: transaction.deposit,
currency: "USD",
});
};
const getEmployeeTransactions = (employeeId: string): ProcessedTransaction[] => {
const employeeTransactions = transactions[employeeId] || [];
return processTransactions(employeeTransactions);
};
const getEmployeeTransactions = (employeeId: string): ProcessedTransaction[] =>
src.getEmployeeTransactions(employeeId).map((t) => ({
date: t.transaction_date,
collection: t.collection_amount,
deposit: t.deposit_amount,
difference: t.difference,
runningBalance: t.runningBalance,
}));
const getEmployeeSummary = (employeeId: string): EmployeeSummary => {
const employeeTransactions = transactions[employeeId] || [];
if (employeeTransactions.length === 0) {
const s = src.getEmployeeSummary(employeeId);
return {
totalCollection: 0,
totalDeposit: 0,
outstandingAmount: 0,
lastTransactionDate: null
};
}
const totalCollection = employeeTransactions.reduce((sum, t) => sum + t.collection, 0);
const totalDeposit = employeeTransactions.reduce((sum, t) => sum + t.deposit, 0);
const outstandingAmount = totalCollection - totalDeposit;
// Find the most recent transaction date
const lastTransactionDate = employeeTransactions
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0]?.date || null;
return {
totalCollection,
totalDeposit,
outstandingAmount,
lastTransactionDate
totalCollection: s.totalCollection,
totalDeposit: s.totalDeposit,
outstandingAmount: s.outstandingAmount,
lastTransactionDate: s.lastTransactionDate,
};
};
return {
employees,
transactions,
transactions: src.transactions,
addTransaction,
getEmployeeTransactions,
getEmployeeSummary
getEmployeeSummary,
};
};
+214 -101
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useEffect, useState, useCallback } from "react";
import { Currency, convert } from "@/lib/currency";
import { api } from "@/lib/api";
export interface Employee {
id: string;
@@ -8,6 +8,7 @@ export interface Employee {
name: string;
email: string;
department: string;
location?: string;
}
export interface Transaction {
@@ -16,6 +17,7 @@ export interface Transaction {
transaction_date: string;
collection_amount: number;
deposit_amount: number;
currency: Currency;
}
export interface ProcessedTransaction extends Transaction {
@@ -24,154 +26,265 @@ export interface ProcessedTransaction extends Transaction {
}
export interface EmployeeSummary {
totalCollectionUSD: number;
totalCollectionLBP: number;
totalDepositUSD: number;
totalDepositLBP: number;
outstandingUSD: number;
outstandingLBP: number;
totalCollection: number;
totalDeposit: number;
outstandingAmount: number;
lastTransactionDate: string | null;
}
export const useSupabaseEmployeeData = () => {
export interface EmployeeOutstandingBalance {
employee_id: string;
emp_id: string;
name: string;
email: string | null;
department: string | null;
location: string | null;
currency: Currency;
manual_collection: number;
manual_deposit: number;
shift_shortage: number;
shift_overage: number;
total_collection: number;
total_deposit: number;
outstanding_amount: number;
last_activity_at: string | null;
}
interface DbEmployee {
id: string;
emp_id: string;
name: string;
email: string | null;
department: string | null;
location: string | null;
}
interface DbTransaction {
id: string;
employee_id: string;
transaction_date: string;
collection_amount: string | number;
deposit_amount: string | number;
currency: Currency;
}
interface DbOutstandingBalance {
employee_id: string;
emp_id: string;
name: string;
email: string | null;
department: string | null;
location: string | null;
currency: Currency;
manual_collection: string | number;
manual_deposit: string | number;
shift_shortage: string | number;
shift_overage: string | number;
total_collection: string | number;
total_deposit: string | number;
outstanding_amount: string | number;
last_activity_at: string | null;
}
function normalizeEmployee(e: DbEmployee): Employee {
return {
id: e.id,
emp_id: e.emp_id,
name: e.name,
email: e.email ?? "",
department: e.department ?? "",
location: e.location ?? "",
};
}
function normalizeTx(t: DbTransaction): Transaction {
return {
id: t.id,
employee_id: t.employee_id,
transaction_date: typeof t.transaction_date === "string"
? t.transaction_date.slice(0, 10)
: t.transaction_date,
collection_amount: Number(t.collection_amount) || 0,
deposit_amount: Number(t.deposit_amount) || 0,
currency: (t.currency || "USD") as Currency,
};
}
function normalizeBalance(row: DbOutstandingBalance): EmployeeOutstandingBalance {
return {
employee_id: row.employee_id,
emp_id: row.emp_id,
name: row.name,
email: row.email,
department: row.department,
location: row.location,
currency: (row.currency || "USD") as Currency,
manual_collection: Number(row.manual_collection) || 0,
manual_deposit: Number(row.manual_deposit) || 0,
shift_shortage: Number(row.shift_shortage) || 0,
shift_overage: Number(row.shift_overage) || 0,
total_collection: Number(row.total_collection) || 0,
total_deposit: Number(row.total_deposit) || 0,
outstanding_amount: Number(row.outstanding_amount) || 0,
last_activity_at: row.last_activity_at,
};
}
export const useSupabaseEmployeeData = (displayCurrency: Currency = "USD") => {
const [employees, setEmployees] = useState<Employee[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [outstandingBalances, setOutstandingBalances] = useState<EmployeeOutstandingBalance[]>([]);
const [loading, setLoading] = useState(true);
// Fetch employees
const fetchEmployees = async () => {
try {
const { data, error } = await supabase
.from('employees' as any)
.select('*')
.order('emp_id');
if (error) {
console.error('Error fetching employees:', error);
return;
}
setEmployees(data || []);
} catch (error) {
console.error('Error fetching employees:', error);
}
};
// Fetch transactions
const fetchTransactions = async () => {
try {
const { data, error } = await supabase
.from('transactions' as any)
.select('*')
.order('transaction_date');
if (error) {
console.error('Error fetching transactions:', error);
return;
}
setTransactions(data || []);
} catch (error) {
console.error('Error fetching transactions:', error);
}
};
// Load data on mount
useEffect(() => {
const loadData = async () => {
const refreshData = useCallback(async () => {
setLoading(true);
await Promise.all([fetchEmployees(), fetchTransactions()]);
const [eRes, tRes, bRes] = await Promise.all([
api.get<DbEmployee[]>("/employees"),
api.get<DbTransaction[]>("/employee_transactions"),
api.fromView<DbOutstandingBalance>("v_employee_outstanding_balances"),
]);
setEmployees((eRes.data ?? []).map(normalizeEmployee));
setTransactions((tRes.data ?? []).map(normalizeTx));
setOutstandingBalances((bRes.data ?? []).map(normalizeBalance));
setLoading(false);
};
loadData();
}, []);
// Add transaction
const addTransaction = async (employeeId: string, transactionData: {
useEffect(() => { refreshData(); }, [refreshData]);
const addTransaction = useCallback(
async (
employeeId: string,
data: {
transaction_date: string;
collection_amount: number;
deposit_amount: number;
}) => {
try {
const { data, error } = await supabase
.from('transactions' as any)
.insert([{
currency?: Currency;
},
) => {
const { data: created, error } = await api.post<DbTransaction>(
"/employee_transactions",
{
employee_id: employeeId,
...transactionData
}] as any)
.select();
transaction_date: data.transaction_date,
collection_amount: data.collection_amount,
deposit_amount: data.deposit_amount,
currency: data.currency ?? "USD",
},
);
if (error || !created) throw new Error(error?.message ?? "Insert failed");
const tx = normalizeTx(created);
setTransactions((cur) => [...cur, tx]);
return [tx];
},
[],
);
if (error) {
console.error('Error adding transaction:', error);
throw error;
const addEmployee = useCallback(
async (emp: Omit<Employee, "id">) => {
const { data: created, error } = await api.post<DbEmployee>("/employees", {
emp_id: emp.emp_id,
name: emp.name,
email: emp.email,
department: emp.department,
location: emp.location,
});
if (error || !created) throw new Error(error?.message ?? "Insert failed");
const e = normalizeEmployee(created);
setEmployees((cur) => {
const idx = cur.findIndex((x) => x.emp_id === e.emp_id);
if (idx >= 0) {
const next = cur.slice();
next[idx] = e;
return next;
}
return [...cur, e];
});
return e;
},
[],
);
// Refresh transactions
await fetchTransactions();
return data;
} catch (error) {
console.error('Error adding transaction:', error);
throw error;
}
};
// Process transactions for an employee with proper clearing logic
const processTransactions = (employeeTransactions: Transaction[]): ProcessedTransaction[] => {
let runningBalance = 0;
return employeeTransactions
.slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime())
.map(transaction => {
const difference = transaction.deposit_amount - transaction.collection_amount;
.map((t) => {
const collectionDisp = convert(t.collection_amount, t.currency, displayCurrency);
const depositDisp = convert(t.deposit_amount, t.currency, displayCurrency);
const difference = depositDisp - collectionDisp;
runningBalance += difference;
return {
...transaction,
difference,
runningBalance
};
return { ...t, difference, runningBalance };
});
};
// Get transactions for a specific employee
const getEmployeeTransactions = (employeeId: string): ProcessedTransaction[] => {
const employeeTransactions = transactions.filter(t => t.employee_id === employeeId);
return processTransactions(employeeTransactions);
};
const getEmployeeTransactions = (employeeId: string): ProcessedTransaction[] =>
processTransactions(transactions.filter((t) => t.employee_id === employeeId));
// Get employee summary
const getEmployeeSummary = (employeeId: string): EmployeeSummary => {
const employeeTransactions = transactions.filter(t => t.employee_id === employeeId);
const employeeTransactions = transactions.filter((t) => t.employee_id === employeeId);
if (employeeTransactions.length === 0) {
return {
totalCollection: 0,
totalDeposit: 0,
outstandingAmount: 0,
lastTransactionDate: null
};
let totalCollectionUSD = 0;
let totalCollectionLBP = 0;
let totalDepositUSD = 0;
let totalDepositLBP = 0;
for (const t of employeeTransactions) {
if (t.currency === "USD") {
totalCollectionUSD += t.collection_amount;
totalDepositUSD += t.deposit_amount;
} else {
totalCollectionLBP += t.collection_amount;
totalDepositLBP += t.deposit_amount;
}
}
const totalCollection = employeeTransactions.reduce((sum, t) => sum + t.collection_amount, 0);
const totalDeposit = employeeTransactions.reduce((sum, t) => sum + t.deposit_amount, 0);
const outstandingUSD = totalCollectionUSD - totalDepositUSD;
const outstandingLBP = totalCollectionLBP - totalDepositLBP;
const totalCollection =
convert(totalCollectionUSD, "USD", displayCurrency) +
convert(totalCollectionLBP, "LBP", displayCurrency);
const totalDeposit =
convert(totalDepositUSD, "USD", displayCurrency) +
convert(totalDepositLBP, "LBP", displayCurrency);
const outstandingAmount = totalCollection - totalDeposit;
const lastTransactionDate = employeeTransactions
.sort((a, b) => new Date(b.transaction_date).getTime() - new Date(a.transaction_date).getTime())[0]?.transaction_date || null;
const lastTransactionDate =
employeeTransactions
.slice()
.sort((a, b) => new Date(b.transaction_date).getTime() - new Date(a.transaction_date).getTime())[0]
?.transaction_date || null;
return {
totalCollectionUSD,
totalCollectionLBP,
totalDepositUSD,
totalDepositLBP,
outstandingUSD,
outstandingLBP,
totalCollection,
totalDeposit,
outstandingAmount,
lastTransactionDate
lastTransactionDate,
};
};
return {
employees,
transactions,
outstandingBalances,
loading,
addTransaction,
addEmployee,
getEmployeeTransactions,
getEmployeeSummary,
refreshData: () => Promise.all([fetchEmployees(), fetchTransactions()])
refreshData,
};
};
+56 -8
View File
@@ -1,11 +1,59 @@
// This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
/**
* Backward-compatible shim that exposes a supabase-shaped client backed
* by the local Express API (`src/lib/api.ts`). Existing components that
* use `supabase.rpc(...)`, `supabase.from(view).select(...).eq(...)` and
* `supabase.auth.*` keep working unchanged.
*/
import { api, type ApiSession } from '@/lib/api';
const SUPABASE_URL = "https://ypdikqcljvradgavpibk.supabase.co";
const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlwZGlrcWNsanZyYWRnYXZwaWJrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDgzNDIwOTgsImV4cCI6MjA2MzkxODA5OH0.TTgI8f_5IFWUVPgGpDTVzNSqIGB-6VzrxFIKfbZrTzg";
type Result<T> = Promise<{ data: T | null; error: { message: string } | null }>;
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
interface SelectBuilder<T> extends Result<T[]> {
eq(column: string, value: string | number | boolean): SelectBuilder<T>;
}
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);
function makeSelectBuilder<T>(view: string): SelectBuilder<T> {
const filters: Record<string, string | number | boolean> = {};
const exec = () => api.fromView<T>(view, filters);
const builder = {
eq(column: string, value: string | number | boolean) {
filters[column] = value;
return builder as SelectBuilder<T>;
},
then(onFulfilled?: Parameters<Promise<unknown>['then']>[0],
onRejected?: Parameters<Promise<unknown>['then']>[1]) {
return exec().then(onFulfilled as never, onRejected as never);
},
} as unknown as SelectBuilder<T>;
return builder;
}
export const supabase = {
auth: {
async signInWithPassword(args: { email: string; password: string }) {
const { data, error } = await api.auth.signInWithPassword(args);
return { data: { session: data.session as ApiSession | null }, error };
},
async signOut() { return api.auth.signOut(); },
async getSession() { return api.auth.getSession(); },
onAuthStateChange(
cb: (event: string, session: ApiSession | null) => void,
) {
return api.auth.onAuthStateChange((event, session) => cb(event, session));
},
},
rpc<T = unknown>(fn: string, args?: Record<string, unknown>) {
return api.rpc<T>(fn, args);
},
from<T = unknown>(view: string) {
return {
select(_cols?: string) {
return makeSelectBuilder<T>(view);
},
};
},
};
export default supabase;
-138
View File
@@ -1,138 +0,0 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
public: {
Tables: {
[_ in never]: never
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type DefaultSchema = Database[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof Database },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const
+180
View File
@@ -0,0 +1,180 @@
/**
* Lightweight API client that talks to the local Express backend.
* Returns supabase-shaped { data, error } so the existing UI keeps working.
*
* The JWT is persisted per browser tab via sessionStorage. There is one auth state listener
* pattern modeled after supabase.auth.onAuthStateChange.
*/
function resolveApiBase(): string {
const raw = import.meta.env.VITE_API_BASE as string | undefined;
// Explicit empty string means "same origin" (the API serves the SPA in prod).
if (raw !== undefined) return raw.replace(/\/$/, '');
if (typeof window === 'undefined') {
return 'http://localhost:4000';
}
const configuredPort = (import.meta.env.VITE_API_PORT as string | undefined)?.trim() || '4000';
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
const hostname = window.location.hostname || 'localhost';
return `${protocol}//${hostname}:${configuredPort}`;
}
const API_BASE: string = resolveApiBase();
const TOKEN_KEY = 'crm_omt_token';
const USER_KEY = 'crm_omt_user';
function getSessionStorage(): Storage | null {
try {
return window.sessionStorage;
} catch {
return null;
}
}
export interface ApiUser {
id: string;
email: string;
full_name?: string | null;
is_system_admin?: boolean;
}
export interface ApiSession {
access_token: string;
user: ApiUser;
}
export interface ApiError {
message: string;
code?: string;
detail?: string;
}
type Listener = (event: 'SIGNED_IN' | 'SIGNED_OUT' | 'INITIAL', session: ApiSession | null) => void;
const listeners = new Set<Listener>();
function emit(event: 'SIGNED_IN' | 'SIGNED_OUT' | 'INITIAL', session: ApiSession | null) {
listeners.forEach((l) => { try { l(event, session); } catch { /* ignore */ } });
}
function getToken(): string | null {
return getSessionStorage()?.getItem(TOKEN_KEY) ?? null;
}
function getStoredUser(): ApiUser | null {
const raw = getSessionStorage()?.getItem(USER_KEY);
if (!raw) return null;
try { return JSON.parse(raw) as ApiUser; } catch { return null; }
}
function setSession(session: ApiSession | null) {
const storage = getSessionStorage();
if (!storage) return;
if (session) {
storage.setItem(TOKEN_KEY, session.access_token);
storage.setItem(USER_KEY, JSON.stringify(session.user));
} else {
storage.removeItem(TOKEN_KEY);
storage.removeItem(USER_KEY);
}
}
async function request<T>(
method: string,
path: string,
body?: unknown,
): Promise<{ data: T | null; error: ApiError | null }> {
const token = getToken();
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch (e) {
return { data: null, error: { message: (e as Error).message } };
}
let payload: unknown = null;
try { payload = await response.json(); } catch { /* empty body */ }
if (!response.ok) {
if (response.status === 401) {
setSession(null);
emit('SIGNED_OUT', null);
}
const err = (payload as { error?: string; code?: string; detail?: string }) || {};
return {
data: null,
error: {
message: err.error || `HTTP ${response.status}`,
code: err.code,
detail: err.detail,
},
};
}
const data = (payload as { data?: T })?.data ?? (payload as T);
return { data: (data ?? null) as T | null, error: null };
}
// ---- public API ------------------------------------------------------
export const api = {
baseUrl: API_BASE,
auth: {
async signInWithPassword(args: { email: string; password: string }) {
const { data, error } = await request<{ token: string; user: ApiUser }>(
'POST', '/auth/login', args,
);
if (error || !data) return { data: { session: null }, error };
const session: ApiSession = { access_token: data.token, user: data.user };
setSession(session);
emit('SIGNED_IN', session);
return { data: { session }, error: null };
},
async signOut() {
const token = getToken();
if (token) await request('POST', '/auth/logout');
setSession(null);
emit('SIGNED_OUT', null);
return { error: null as ApiError | null };
},
getSession(): { data: { session: ApiSession | null } } {
const token = getToken();
const user = getStoredUser();
const session = token && user ? { access_token: token, user } : null;
return { data: { session } };
},
onAuthStateChange(cb: Listener) {
listeners.add(cb);
return {
data: { subscription: { unsubscribe: () => listeners.delete(cb) } },
};
},
},
/** Call a Postgres RPC. `args` are passed as named parameters. */
rpc<T = unknown>(fn: string, args?: Record<string, unknown>) {
return request<T>('POST', `/rpc/${fn}`, args ?? {});
},
/** Read from a whitelisted view, optionally filtered by exact match. */
async fromView<T = unknown>(view: string, filters: Record<string, string | number | boolean> = {}) {
const qs = new URLSearchParams(
Object.entries(filters).map(([k, v]) => [k, String(v)]),
).toString();
return request<T[]>('GET', `/from/${view}${qs ? `?${qs}` : ''}`);
},
/** Plain GET against the API. */
get<T = unknown>(path: string) { return request<T>('GET', path); },
/** Plain POST against the API. */
post<T = unknown>(path: string, body?: unknown) { return request<T>('POST', path, body); },
/** Plain DELETE against the API. */
delete<T = unknown>(path: string) { return request<T>('DELETE', path); },
};
export type Api = typeof api;
+56
View File
@@ -0,0 +1,56 @@
// Currency utilities for the Cash Management System.
// Supports US Dollar (USD) and Lebanese Pound (LBP).
export type Currency = "USD" | "LBP";
// 1 USD = 89,500 LBP (configurable via Admin if needed later).
export const DEFAULT_USD_TO_LBP = 89500;
const RATE_KEY = "currency_usd_to_lbp_rate";
export function getUsdToLbpRate(): number {
try {
const raw = localStorage.getItem(RATE_KEY);
if (raw) {
const n = Number(raw);
if (Number.isFinite(n) && n > 0) return n;
}
} catch {
/* noop */
}
return DEFAULT_USD_TO_LBP;
}
export function setUsdToLbpRate(rate: number) {
if (!Number.isFinite(rate) || rate <= 0) return;
localStorage.setItem(RATE_KEY, String(rate));
}
// Convert an amount from one currency to another.
export function convert(amount: number, from: Currency, to: Currency): number {
if (from === to) return amount;
const rate = getUsdToLbpRate();
if (from === "USD" && to === "LBP") return amount * rate;
if (from === "LBP" && to === "USD") return amount / rate;
return amount;
}
// Format a number as currency. LBP has no decimals and uses thousands grouping.
export function formatCurrency(amount: number, currency: Currency): string {
if (currency === "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
// LBP
return (
new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
}).format(Math.round(amount)) + " LBP"
);
}
export const CURRENCY_OPTIONS: Currency[] = ["USD", "LBP"];
+70
View File
@@ -0,0 +1,70 @@
// Service category catalog mirrored from `app.services` seed (migration 0003).
// Keep in sync with the DB; the DB is the source of truth — this list is
// only used to render the Select before the user's session is online.
//
// Categories let the UI group the dropdown sensibly: "Money transfer",
// "Telecom recharge", "Bills", "Goods & repair", "Other".
export type ServiceCategory =
| "money_transfer"
| "telecom_recharge"
| "bills"
| "goods"
| "repair"
| "refund";
export interface ServiceDef {
code: string;
label: string;
category: ServiceCategory;
requiresExternalRef: boolean;
requiresBeneficiary: boolean;
}
export const SERVICES: ServiceDef[] = [
// Money transfer
{ code: "OMT_SEND", label: "OMT — Send", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: true },
{ code: "OMT_RECEIVE", label: "OMT — Receive", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: false },
{ code: "WU_SEND", label: "Western Union — Send", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: true },
{ code: "WU_RECEIVE", label: "Western Union — Recv", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: false },
{ code: "WHISH_SEND", label: "Whish — Send", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: true },
{ code: "WHISH_RECEIVE",label: "Whish — Receive", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: false },
// Telecom recharge
{ code: "ALFA_RECHARGE", label: "Alfa recharge", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
{ code: "TOUCH_RECHARGE", label: "touch recharge", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
{ code: "OGERO_RECHARGE", label: "Ogero recharge", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
{ code: "INTERNET_RECHARGE", label: "Internet voucher", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
// Bills
{ code: "OMT_BILL", label: "Bill payment (via OMT)", category: "bills", requiresExternalRef: true, requiresBeneficiary: false },
{ code: "EDL_BILL", label: "EDL electricity bill", category: "bills", requiresExternalRef: true, requiresBeneficiary: false },
// Goods & repair
{ code: "GOODS_SALE", label: "Goods sale", category: "goods", requiresExternalRef: false, requiresBeneficiary: false },
{ code: "REPAIR", label: "Phone / device repair", category: "repair", requiresExternalRef: false, requiresBeneficiary: false },
// Refund (issued via app.issue_refund only — listed here so refund txns render)
{ code: "REFUND", label: "Refund", category: "refund", requiresExternalRef: false, requiresBeneficiary: false },
];
export const SERVICES_BY_CATEGORY: Record<ServiceCategory, ServiceDef[]> = SERVICES.reduce(
(acc, s) => {
(acc[s.category] ||= []).push(s);
return acc;
},
{} as Record<ServiceCategory, ServiceDef[]>,
);
export const CATEGORY_LABEL: Record<ServiceCategory, string> = {
money_transfer: "Money transfer",
telecom_recharge: "Telecom recharge",
bills: "Bills",
goods: "Goods",
repair: "Repair",
refund: "Refund",
};
export function findService(code: string): ServiceDef | undefined {
return SERVICES.find((s) => s.code === code);
}
+122 -7
View File
@@ -5,14 +5,30 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { OutstandingReportDashboard } from "@/components/OutstandingReportDashboard";
import { DetailedEmployeePaymentReport } from "@/components/DetailedEmployeePaymentReport";
import { AdminDataEntryModal } from "@/components/AdminDataEntryModal";
import { TransactionEntry } from "@/components/TransactionEntry";
import { ShiftControl } from "@/components/ShiftControl";
import { LoginPage } from "@/components/LoginPage";
import { UserManagement } from "@/components/UserManagement";
import { ManagerConsole } from "@/components/ManagerConsole";
import { OwnerOverview } from "@/components/OwnerOverview";
import { TransactionCenter } from "@/components/TransactionCenter";
import { useAuth } from "@/hooks/useAuth";
import { LogOut } from "lucide-react";
const Index = () => {
const { user, loading, signOut } = useAuth();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isTxnOpen, setIsTxnOpen] = useState(false);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const isAdmin = user?.role === "admin";
const isOwner = user?.role === "owner";
const canEnterTransactions = user?.role === "owner" || user?.role === "employee";
const defaultTab = isAdmin ? "users" : isOwner ? "overview" : "shift";
const [activeTab, setActiveTab] = useState<string>(defaultTab);
React.useEffect(() => {
setActiveTab(defaultTab);
}, [defaultTab]);
const handleDataUpdate = () => {
setRefreshTrigger(prev => prev + 1);
@@ -46,15 +62,28 @@ const Index = () => {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-gray-800">Dashboard</h1>
<p className="text-sm text-gray-500">Cash Management System</p>
<p className="text-sm text-gray-500">
Cash Management System {user ? `· ${user.name} (${user.role})` : ""}
</p>
</div>
<div className="flex items-center gap-4">
{canEnterTransactions && (
<>
<Button
onClick={() => setIsTxnOpen(true)}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium"
>
New Transaction
</Button>
<Button
onClick={() => setIsModalOpen(true)}
className="bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg font-medium"
variant="outline"
className="px-6 py-2 rounded-lg font-medium"
>
Insert Employee Data
Submit Collection / Deposit
</Button>
</>
)}
<Button
onClick={handleLogout}
variant="outline"
@@ -68,29 +97,110 @@ const Index = () => {
</div>
<div className="container mx-auto p-6">
<Tabs defaultValue="outstanding" className="space-y-6">
<TabsList className="grid w-full grid-cols-2 bg-white rounded-lg shadow-sm p-1 border">
<Tabs
value={activeTab}
onValueChange={(next) => {
if (isTxnOpen || isModalOpen) return;
setActiveTab(next);
}}
className="space-y-6"
>
<TabsList className="flex w-full flex-wrap gap-1 h-auto justify-start bg-white rounded-lg shadow-sm p-1.5 border">
{isOwner && (
<TabsTrigger
value="overview"
className="flex-1 min-w-[120px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
>
Overview
</TabsTrigger>
)}
{canEnterTransactions && (
<>
<TabsTrigger
value="shift"
className="flex-1 min-w-[120px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
>
Shift
</TabsTrigger>
<TabsTrigger
value="transactions"
className="flex-1 min-w-[140px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
>
Transactions
</TabsTrigger>
<TabsTrigger
value="outstanding"
className="data-[state=active]:bg-purple-600 data-[state=active]:text-white rounded-md py-3 text-base font-medium transition-all duration-200"
className="flex-1 min-w-[120px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
>
Outstanding Report
</TabsTrigger>
<TabsTrigger
value="payment"
className="data-[state=active]:bg-purple-600 data-[state=active]:text-white rounded-md py-3 text-base font-medium transition-all duration-200"
className="flex-1 min-w-[160px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
>
Employee Payment Report
</TabsTrigger>
</>
)}
{isAdmin && (
<TabsTrigger
value="manager"
className="flex-1 min-w-[140px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
>
Manager Console
</TabsTrigger>
)}
{isAdmin && (
<TabsTrigger
value="users"
className="flex-1 min-w-[140px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
>
User Management
</TabsTrigger>
)}
</TabsList>
{canEnterTransactions && (
<TabsContent value="shift">
<ShiftControl />
</TabsContent>
)}
{canEnterTransactions && (
<TabsContent value="transactions">
<TransactionCenter />
</TabsContent>
)}
{isOwner && (
<TabsContent value="overview">
<OwnerOverview key={refreshTrigger} />
</TabsContent>
)}
{canEnterTransactions && (
<TabsContent value="outstanding">
<OutstandingReportDashboard key={refreshTrigger} />
</TabsContent>
)}
{canEnterTransactions && (
<TabsContent value="payment">
<DetailedEmployeePaymentReport key={refreshTrigger} />
</TabsContent>
)}
{isAdmin && (
<TabsContent value="manager">
<ManagerConsole />
</TabsContent>
)}
{isAdmin && (
<TabsContent value="users">
<UserManagement />
</TabsContent>
)}
</Tabs>
<AdminDataEntryModal
@@ -98,6 +208,11 @@ const Index = () => {
onClose={() => setIsModalOpen(false)}
onDataUpdate={handleDataUpdate}
/>
<TransactionEntry
isOpen={isTxnOpen}
onClose={() => setIsTxnOpen(false)}
onCreated={handleDataUpdate}
/>
</div>
</div>
);
+348
View File
@@ -0,0 +1,348 @@
-- =====================================================================
-- Migration 0001 — Auth, organizational hierarchy, RLS foundations.
--
-- Implements roadmap Step 1 (identity & access) and Step 2 (org & master
-- data). No money tables yet; those come in 0002+. Every table is created
-- with RLS enabled and a deny-by-default posture; specific policies are
-- added inline.
--
-- Threat-model rows addressed: 9, 18, 20, 24, 25.
-- =====================================================================
-- Required extensions ---------------------------------------------------
create extension if not exists "pgcrypto"; -- gen_random_uuid, digest
create extension if not exists "citext"; -- case-insensitive text
-- Dedicated schema for app data (keeps `public` clean) ------------------
create schema if not exists app;
-- Revoke broad defaults; we will grant explicitly per role.
revoke all on schema app from public;
grant usage on schema app to authenticated;
-- =====================================================================
-- Roles
-- =====================================================================
-- We model business roles as an enum, separate from Postgres/Supabase
-- roles. Supabase still uses `authenticated`/`anon`; the business role is
-- read from `app.user_shop_assignments` per shop.
do $$ begin
create type app.business_role as enum ('owner', 'manager', 'cashier', 'auditor');
exception when duplicate_object then null; end $$;
-- =====================================================================
-- Shops, tills, users
-- =====================================================================
create table if not exists app.shops (
id uuid primary key default gen_random_uuid(),
name text not null,
address text,
omt_agent_code text unique,
alfa_dealer_code text unique,
touch_dealer_code text unique,
created_at timestamptz not null default now(),
created_by uuid references auth.users(id)
);
create table if not exists app.tills (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
name text not null,
-- Pin a till to a hardware device. New devices must be registered by an
-- owner; blocks vector #20 (second undeclared till on same machine).
device_fingerprint text unique,
is_active boolean not null default true,
created_at timestamptz not null default now(),
unique (shop_id, name)
);
-- Profile mirror of auth.users so we can attach business attributes
-- without granting clients access to the auth schema.
create table if not exists app.user_profiles (
user_id uuid primary key references auth.users(id) on delete cascade,
full_name text not null,
phone text,
-- 6-digit PIN, salted+hashed. Never store plaintext.
pin_hash text,
pin_set_at timestamptz,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create table if not exists app.user_shop_assignments (
user_id uuid not null references auth.users(id) on delete cascade,
shop_id uuid not null references app.shops(id) on delete cascade,
role app.business_role not null,
assigned_at timestamptz not null default now(),
assigned_by uuid references auth.users(id),
primary key (user_id, shop_id)
);
create index if not exists idx_assignments_shop on app.user_shop_assignments(shop_id);
create index if not exists idx_assignments_role on app.user_shop_assignments(shop_id, role);
-- =====================================================================
-- Helper functions (SECURITY DEFINER) used by RLS policies.
-- These run with the function owner's privileges, so they can read
-- assignment rows even when the calling user cannot read the table.
-- =====================================================================
create or replace function app.current_user_id()
returns uuid
language sql
stable
as $$ select auth.uid() $$;
create or replace function app.has_role_in_shop(p_shop uuid, p_role app.business_role)
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1
from app.user_shop_assignments a
where a.user_id = auth.uid()
and a.shop_id = p_shop
and a.role = p_role
);
$$;
create or replace function app.has_any_role_in_shop(p_shop uuid, p_roles app.business_role[])
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1
from app.user_shop_assignments a
where a.user_id = auth.uid()
and a.shop_id = p_shop
and a.role = any(p_roles)
);
$$;
create or replace function app.is_owner_anywhere()
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1 from app.user_shop_assignments a
where a.user_id = auth.uid() and a.role = 'owner'
);
$$;
revoke all on function app.has_role_in_shop(uuid, app.business_role) from public;
revoke all on function app.has_any_role_in_shop(uuid, app.business_role[]) from public;
revoke all on function app.is_owner_anywhere() from public;
grant execute on function app.has_role_in_shop(uuid, app.business_role) to authenticated;
grant execute on function app.has_any_role_in_shop(uuid, app.business_role[]) to authenticated;
grant execute on function app.is_owner_anywhere() to authenticated;
-- =====================================================================
-- PIN management. Plaintext PINs never leave the server.
-- =====================================================================
create or replace function app.set_my_pin(p_pin text)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
if p_pin !~ '^[0-9]{6}$' then
raise exception 'PIN must be exactly 6 digits';
end if;
insert into app.user_profiles(user_id, full_name, pin_hash, pin_set_at)
values (auth.uid(), coalesce((select full_name from app.user_profiles where user_id = auth.uid()), 'Unnamed'),
crypt(p_pin, gen_salt('bf', 10)), now())
on conflict (user_id) do update
set pin_hash = crypt(p_pin, gen_salt('bf', 10)),
pin_set_at = now();
end;
$$;
create or replace function app.verify_my_pin(p_pin text)
returns boolean
language plpgsql
security definer
set search_path = app, public
as $$
declare h text;
begin
select pin_hash into h from app.user_profiles where user_id = auth.uid();
if h is null then return false; end if;
return h = crypt(p_pin, h);
end;
$$;
revoke all on function app.set_my_pin(text) from public;
revoke all on function app.verify_my_pin(text) from public;
grant execute on function app.set_my_pin(text) to authenticated;
grant execute on function app.verify_my_pin(text) to authenticated;
-- =====================================================================
-- Audit log of authentication / authorization events.
-- Append-only: revoke UPDATE and DELETE; only INSERT via function.
-- =====================================================================
create table if not exists app.auth_events (
id bigserial primary key,
occurred_at timestamptz not null default now(),
user_id uuid,
event_type text not null, -- login, pin_ok, pin_fail, role_change, device_register, ...
shop_id uuid,
device_fingerprint text,
metadata jsonb not null default '{}'::jsonb
);
create index if not exists idx_auth_events_user on app.auth_events(user_id, occurred_at desc);
create index if not exists idx_auth_events_shop on app.auth_events(shop_id, occurred_at desc);
create or replace function app.log_auth_event(
p_event_type text,
p_shop uuid,
p_device text,
p_metadata jsonb
)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
insert into app.auth_events(user_id, event_type, shop_id, device_fingerprint, metadata)
values (auth.uid(), p_event_type, p_shop, p_device, coalesce(p_metadata, '{}'::jsonb));
end;
$$;
revoke all on function app.log_auth_event(text, uuid, text, jsonb) from public;
grant execute on function app.log_auth_event(text, uuid, text, jsonb) to authenticated;
-- =====================================================================
-- RLS — deny by default, then allow per role.
-- =====================================================================
alter table app.shops enable row level security;
alter table app.tills enable row level security;
alter table app.user_profiles enable row level security;
alter table app.user_shop_assignments enable row level security;
alter table app.auth_events enable row level security;
-- Force RLS even for table owners (defense in depth against insider edits,
-- threat-model row #24).
alter table app.shops force row level security;
alter table app.tills force row level security;
alter table app.user_profiles force row level security;
alter table app.user_shop_assignments force row level security;
alter table app.auth_events force row level security;
-- shops: owners and assigned users can see their shops.
drop policy if exists shops_select on app.shops;
create policy shops_select on app.shops
for select to authenticated
using (
app.is_owner_anywhere()
or exists (
select 1 from app.user_shop_assignments a
where a.shop_id = shops.id and a.user_id = auth.uid()
)
);
-- Only owners can create/modify shops, and never via direct UPDATE of
-- security-relevant columns; we still permit it here but real changes
-- should go through dedicated functions later.
drop policy if exists shops_write_owner on app.shops;
create policy shops_write_owner on app.shops
for all to authenticated
using (app.is_owner_anywhere())
with check (app.is_owner_anywhere());
-- tills: visible to everyone assigned to the shop, writable only by owners.
drop policy if exists tills_select on app.tills;
create policy tills_select on app.tills
for select to authenticated
using (
app.is_owner_anywhere()
or exists (
select 1 from app.user_shop_assignments a
where a.shop_id = tills.shop_id and a.user_id = auth.uid()
)
);
drop policy if exists tills_write_owner on app.tills;
create policy tills_write_owner on app.tills
for all to authenticated
using (app.has_role_in_shop(tills.shop_id, 'owner'))
with check (app.has_role_in_shop(tills.shop_id, 'owner'));
-- user_profiles: a user can read/update their own profile (but PIN is
-- changed only via the set_my_pin function). Owners can read all.
drop policy if exists profiles_select_self_or_owner on app.user_profiles;
create policy profiles_select_self_or_owner on app.user_profiles
for select to authenticated
using (user_id = auth.uid() or app.is_owner_anywhere());
drop policy if exists profiles_update_self on app.user_profiles;
create policy profiles_update_self on app.user_profiles
for update to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());
-- user_shop_assignments: a user can see their own assignments; owners can
-- see/manage all assignments in their own shops.
drop policy if exists assignments_select on app.user_shop_assignments;
create policy assignments_select on app.user_shop_assignments
for select to authenticated
using (
user_id = auth.uid()
or app.has_role_in_shop(shop_id, 'owner')
);
drop policy if exists assignments_write_owner on app.user_shop_assignments;
create policy assignments_write_owner on app.user_shop_assignments
for all to authenticated
using (app.has_role_in_shop(shop_id, 'owner'))
with check (app.has_role_in_shop(shop_id, 'owner'));
-- auth_events: nobody writes directly; only the log_auth_event function.
-- Reads: a user sees their own events; owners see all in their shops.
revoke insert, update, delete on app.auth_events from authenticated;
drop policy if exists auth_events_select on app.auth_events;
create policy auth_events_select on app.auth_events
for select to authenticated
using (
user_id = auth.uid()
or (shop_id is not null and app.has_role_in_shop(shop_id, 'owner'))
);
-- =====================================================================
-- Hard prohibitions — no DELETE on auth_events from anyone (including
-- service role used by the app). Only DBA at psql can DELETE, and that
-- itself should be audited at the infrastructure level.
-- =====================================================================
revoke delete on app.auth_events from authenticated;
-- Note: in Supabase, the `service_role` bypasses RLS but still respects
-- table grants. Revoke explicitly:
do $$ begin
if exists (select 1 from pg_roles where rolname = 'service_role') then
execute 'revoke delete on app.auth_events from service_role';
execute 'revoke update on app.auth_events from service_role';
end if;
end $$;
-- =====================================================================
-- Grants for ordinary table access (RLS still applies).
-- =====================================================================
grant select on app.shops to authenticated;
grant insert, update on app.shops to authenticated;
grant select on app.tills to authenticated;
grant insert, update on app.tills to authenticated;
grant select, update on app.user_profiles to authenticated;
grant select, insert, update, delete on app.user_shop_assignments to authenticated;
grant select on app.auth_events to authenticated;
-- End migration 0001 ----------------------------------------------------
@@ -0,0 +1,391 @@
-- =====================================================================
-- Migration 0002 — Shifts and cash drawer (roadmap Step 3).
--
-- Implements the cash-control backbone:
-- * One open shift per till at any time (vector #20).
-- * Blind close: cashier declares cash, expected is computed by the
-- system; both are stored, with variance (vectors #7, #14).
-- * Cash movements typed and append-only (vector #2, #22).
-- * No backdating: occurred_at = now() server-side (vector #25).
-- * Forced shift close before next shift opens (vector #8).
--
-- Threat-model rows addressed: 2, 7, 8, 14, 20, 22, 25.
-- =====================================================================
-- =====================================================================
-- Enums
-- =====================================================================
do $$ begin
create type app.shift_status as enum ('open', 'declared', 'closed');
exception when duplicate_object then null; end $$;
do $$ begin
create type app.cash_movement_type as enum (
'sale_in', -- cash received from a customer
'payout_out', -- cash paid to a customer (e.g. OMT receive)
'drop_to_safe', -- cashier removes cash from till to safe
'bank_deposit', -- cash leaves the shop to the bank
'expense', -- petty cash spent
'fx_swap_in', -- one leg of a currency exchange
'fx_swap_out', -- the other leg
'opening_float', -- recorded at shift open
'adjustment' -- manager-approved correction (always audited)
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.currency_code as enum ('USD', 'LBP');
exception when duplicate_object then null; end $$;
-- =====================================================================
-- Shifts
-- =====================================================================
create table if not exists app.shifts (
id uuid primary key default gen_random_uuid(),
till_id uuid not null references app.tills(id) on delete restrict,
shop_id uuid not null references app.shops(id) on delete restrict,
user_id uuid not null references auth.users(id) on delete restrict,
status app.shift_status not null default 'open',
opened_at timestamptz not null default now(),
opened_by uuid not null references auth.users(id),
opening_usd numeric(14,2) not null check (opening_usd >= 0),
opening_lbp numeric(18,0) not null check (opening_lbp >= 0),
-- Phase 1 of close: cashier declares the count.
declared_at timestamptz,
declared_close_usd numeric(14,2) check (declared_close_usd >= 0),
declared_close_lbp numeric(18,0) check (declared_close_lbp >= 0),
-- Phase 2 of close: system computes expected; cashier cannot edit.
closed_at timestamptz,
closed_by uuid references auth.users(id),
expected_close_usd numeric(14,2),
expected_close_lbp numeric(18,0),
variance_usd numeric(14,2),
variance_lbp numeric(18,0),
-- One open or declared shift per till at any time.
constraint shifts_status_dates_ok check (
(status = 'open' and declared_at is null and closed_at is null)
or (status = 'declared' and declared_at is not null and closed_at is null)
or (status = 'closed' and declared_at is not null and closed_at is not null)
)
);
create index if not exists idx_shifts_till_status on app.shifts(till_id, status);
create index if not exists idx_shifts_shop_open on app.shifts(shop_id, opened_at desc);
create index if not exists idx_shifts_user on app.shifts(user_id, opened_at desc);
-- Partial unique index: at most one non-closed shift per till.
create unique index if not exists uq_one_active_shift_per_till
on app.shifts(till_id) where status <> 'closed';
-- =====================================================================
-- Cash movements (append-only)
-- =====================================================================
create table if not exists app.cash_movements (
id uuid primary key default gen_random_uuid(),
shift_id uuid not null references app.shifts(id) on delete restrict,
occurred_at timestamptz not null default now(),
type app.cash_movement_type not null,
currency app.currency_code not null,
-- Signed amount: positive = cash into the till, negative = cash out.
amount numeric(18,2) not null check (amount <> 0),
ref_txn_id uuid, -- filled later when ledger table exists (FK added in 0003)
note text,
created_by uuid not null references auth.users(id) default auth.uid(),
created_at timestamptz not null default now()
);
create index if not exists idx_cash_mov_shift on app.cash_movements(shift_id, occurred_at);
create index if not exists idx_cash_mov_txn on app.cash_movements(ref_txn_id);
-- =====================================================================
-- Triggers — block edits and back-dating
-- =====================================================================
-- Cash movements: insert-only.
create or replace function app.cash_movements_no_update_delete()
returns trigger language plpgsql as $$
begin
raise exception 'cash_movements is append-only';
end;
$$;
drop trigger if exists trg_cash_mov_no_update on app.cash_movements;
create trigger trg_cash_mov_no_update
before update or delete on app.cash_movements
for each row execute function app.cash_movements_no_update_delete();
-- Force occurred_at = now() and created_by = auth.uid() on insert.
create or replace function app.cash_movements_stamp()
returns trigger language plpgsql as $$
begin
new.occurred_at := now(); -- vector #25: no backdating
new.created_at := now();
new.created_by := auth.uid();
-- The shift must be open and belong to the same user / till must be active.
if not exists (
select 1 from app.shifts s
where s.id = new.shift_id and s.status = 'open'
) then
raise exception 'cash movement requires an OPEN shift (got shift %)', new.shift_id;
end if;
return new;
end;
$$;
drop trigger if exists trg_cash_mov_stamp on app.cash_movements;
create trigger trg_cash_mov_stamp
before insert on app.cash_movements
for each row execute function app.cash_movements_stamp();
-- Shifts: tightly constrain UPDATE paths. Only specific transitions are
-- allowed and they must come through the SECURITY DEFINER functions
-- below (which set a session GUC the trigger checks for).
create or replace function app.shifts_guard_update()
returns trigger language plpgsql as $$
begin
if current_setting('app.shift_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on app.shifts is not allowed; use app.declare_close / app.finalize_close';
end if;
return new;
end;
$$;
drop trigger if exists trg_shifts_guard_update on app.shifts;
create trigger trg_shifts_guard_update
before update on app.shifts
for each row execute function app.shifts_guard_update();
create or replace function app.shifts_no_delete()
returns trigger language plpgsql as $$
begin
raise exception 'shifts cannot be deleted';
end;
$$;
drop trigger if exists trg_shifts_no_delete on app.shifts;
create trigger trg_shifts_no_delete
before delete on app.shifts
for each row execute function app.shifts_no_delete();
-- =====================================================================
-- SECURITY DEFINER functions — the only legal way to mutate shifts.
-- =====================================================================
-- Open a new shift on a till for the current user.
create or replace function app.open_shift(
p_till_id uuid,
p_opening_usd numeric,
p_opening_lbp numeric
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_shift uuid;
begin
if p_opening_usd is null or p_opening_lbp is null then
raise exception 'opening counts are required';
end if;
if p_opening_usd < 0 or p_opening_lbp < 0 then
raise exception 'opening counts must be non-negative';
end if;
select shop_id into v_shop from app.tills where id = p_till_id and is_active;
if v_shop is null then
raise exception 'till % not found or inactive', p_till_id;
end if;
-- Caller must be a cashier or manager in this shop.
if not app.has_any_role_in_shop(v_shop, array['cashier','manager']::app.business_role[]) then
raise exception 'not authorized to open a shift on this till';
end if;
-- Reject if any non-closed shift exists on this till.
if exists (select 1 from app.shifts where till_id = p_till_id and status <> 'closed') then
raise exception 'till % already has an active shift; close it first', p_till_id;
end if;
insert into app.shifts(till_id, shop_id, user_id, opened_by, opening_usd, opening_lbp)
values (p_till_id, v_shop, auth.uid(), auth.uid(), p_opening_usd, p_opening_lbp)
returning id into v_shift;
-- Record the opening float as a cash movement for clean ledgers.
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (v_shift, 'opening_float', 'USD', p_opening_usd, 'opening float'),
(v_shift, 'opening_float', 'LBP', p_opening_lbp, 'opening float');
perform app.log_auth_event('shift_opened', v_shop, null,
jsonb_build_object('shift_id', v_shift, 'till_id', p_till_id));
return v_shift;
end;
$$;
-- Phase 1 of close: cashier declares the cash count. Expected is NOT
-- revealed until phase 2 (vector #14: blind close).
create or replace function app.declare_close(
p_shift_id uuid,
p_declared_close_usd numeric,
p_declared_close_lbp numeric
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare s record;
begin
select * into s from app.shifts where id = p_shift_id;
if s.id is null then raise exception 'shift not found'; end if;
if s.user_id <> auth.uid() and not app.has_role_in_shop(s.shop_id, 'manager') then
raise exception 'only the shift owner or a manager may declare close';
end if;
if s.status <> 'open' then
raise exception 'shift % is not open (status=%)', p_shift_id, s.status;
end if;
if p_declared_close_usd is null or p_declared_close_lbp is null
or p_declared_close_usd < 0 or p_declared_close_lbp < 0 then
raise exception 'declared counts must be non-negative numbers';
end if;
perform set_config('app.shift_internal', 'on', true);
update app.shifts
set status = 'declared',
declared_at = now(),
declared_close_usd = p_declared_close_usd,
declared_close_lbp = p_declared_close_lbp
where id = p_shift_id;
perform set_config('app.shift_internal', 'off', true);
perform app.log_auth_event('shift_declared', s.shop_id, null,
jsonb_build_object('shift_id', p_shift_id));
end;
$$;
-- Phase 2 of close: compute expected and variance, lock the shift.
create or replace function app.finalize_close(p_shift_id uuid)
returns table (
expected_usd numeric,
expected_lbp numeric,
variance_usd numeric,
variance_lbp numeric
)
language plpgsql
security definer
set search_path = app, public
as $$
declare
s record;
v_exp_usd numeric;
v_exp_lbp numeric;
begin
select * into s from app.shifts where id = p_shift_id;
if s.id is null then raise exception 'shift not found'; end if;
if s.status <> 'declared' then
raise exception 'shift % must be in DECLARED state to finalize (was %)', p_shift_id, s.status;
end if;
if s.user_id <> auth.uid() and not app.has_role_in_shop(s.shop_id, 'manager') then
raise exception 'only the shift owner or a manager may finalize close';
end if;
-- Expected = sum of signed cash movements in each currency.
-- opening_float rows are already part of cash_movements, so the sum is
-- the full expected drawer count.
select
coalesce(sum(case when currency = 'USD' then amount end), 0),
coalesce(sum(case when currency = 'LBP' then amount end), 0)
into v_exp_usd, v_exp_lbp
from app.cash_movements
where shift_id = p_shift_id;
perform set_config('app.shift_internal', 'on', true);
update app.shifts
set status = 'closed',
closed_at = now(),
closed_by = auth.uid(),
expected_close_usd = v_exp_usd,
expected_close_lbp = v_exp_lbp,
variance_usd = s.declared_close_usd - v_exp_usd,
variance_lbp = s.declared_close_lbp - v_exp_lbp
where id = p_shift_id;
perform set_config('app.shift_internal', 'off', true);
perform app.log_auth_event('shift_closed', s.shop_id, null,
jsonb_build_object(
'shift_id', p_shift_id,
'variance_usd', s.declared_close_usd - v_exp_usd,
'variance_lbp', s.declared_close_lbp - v_exp_lbp
));
return query
select v_exp_usd, v_exp_lbp,
s.declared_close_usd - v_exp_usd,
s.declared_close_lbp - v_exp_lbp;
end;
$$;
revoke all on function app.open_shift(uuid, numeric, numeric) from public;
revoke all on function app.declare_close(uuid, numeric, numeric) from public;
revoke all on function app.finalize_close(uuid) from public;
grant execute on function app.open_shift(uuid, numeric, numeric) to authenticated;
grant execute on function app.declare_close(uuid, numeric, numeric) to authenticated;
grant execute on function app.finalize_close(uuid) to authenticated;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.shifts enable row level security;
alter table app.cash_movements enable row level security;
alter table app.shifts force row level security;
alter table app.cash_movements force row level security;
-- Block direct INSERT/UPDATE on shifts; only the SECURITY DEFINER
-- functions above (which run as the function owner) may write.
revoke insert, update, delete on app.shifts from authenticated;
drop policy if exists shifts_select on app.shifts;
create policy shifts_select on app.shifts
for select to authenticated
using (
user_id = auth.uid()
or app.has_any_role_in_shop(shop_id, array['owner','manager','auditor']::app.business_role[])
);
-- Cash movements: direct INSERT permitted (with RLS check) so cashiers
-- can record sale_in / payout_out from the txn flow; UPDATE/DELETE are
-- already blocked by triggers.
drop policy if exists cash_mov_select on app.cash_movements;
create policy cash_mov_select on app.cash_movements
for select to authenticated
using (
exists (
select 1 from app.shifts s
where s.id = cash_movements.shift_id
and (
s.user_id = auth.uid()
or app.has_any_role_in_shop(s.shop_id, array['owner','manager','auditor']::app.business_role[])
)
)
);
drop policy if exists cash_mov_insert_in_open_shift on app.cash_movements;
create policy cash_mov_insert_in_open_shift on app.cash_movements
for insert to authenticated
with check (
exists (
select 1 from app.shifts s
where s.id = cash_movements.shift_id
and s.status = 'open'
and s.user_id = auth.uid()
)
);
grant select on app.shifts to authenticated;
grant select, insert on app.cash_movements to authenticated;
-- End migration 0002 ----------------------------------------------------
@@ -0,0 +1,525 @@
-- =====================================================================
-- Migration 0003 — Universal transaction ledger (roadmap Step 4).
--
-- One append-only table for every customer-facing transaction
-- (OMT send/receive, bill payment, recharge, goods sale, etc.).
-- Service-specific detail tables are added in 0004.
--
-- Design choices and the threats they kill:
--
-- * INSERT-only at the SQL level. UPDATE is allowed only by the
-- dedicated `void_transaction` function, and it can only flip the
-- status to 'voided' plus set void fields. Triggers enforce this even
-- against superuser app roles. (vectors #1, #2, #11, #18)
--
-- * Sequential `reference_no` per shop, allocated by a Postgres
-- sequence inside a SECURITY DEFINER function — gaps are visible and
-- a daily report can flag missing numbers. (vector #1)
--
-- * Row hash chain: each row stores a sha256 of its own canonical
-- content + the previous row's hash for the same shop. Anchored
-- daily off-site, this detects silent edits even by an insider DBA.
-- (vector #24)
--
-- * `external_ref` (OMT code, recharge confirmation, etc.) is unique
-- per provider — blocks replay of an old receipt to a new customer.
-- (vector #19)
--
-- * Server-stamped `occurred_at`, `created_by`, and shift/shop/till
-- ids — cashier can not backdate or attribute to someone else.
-- (vectors #20, #25)
--
-- * Voids are bound to a 10-minute window (configurable) for cashier
-- self-service, and require a manager `void_approved_by` after that.
-- (vector #11)
--
-- * Cash movements (0002) get an FK to this ledger so every cash
-- in/out is traceable to a transaction or to an explicit non-sale
-- movement (drop, expense, swap...).
-- =====================================================================
-- =====================================================================
-- Enums and reference data
-- =====================================================================
do $$ begin
create type app.txn_status as enum ('completed', 'voided');
exception when duplicate_object then null; end $$;
do $$ begin
create type app.payment_method as enum (
'cash_usd', 'cash_lbp', 'whish', 'omt_wallet', 'card', 'bank_transfer'
);
exception when duplicate_object then null; end $$;
-- Service catalog (seeded at the bottom of this file).
create table if not exists app.services (
code text primary key,
name text not null,
category text not null,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
-- Per-shop receipt-number sequences ------------------------------------
create table if not exists app.shop_sequences (
shop_id uuid primary key references app.shops(id) on delete cascade,
next_value bigint not null default 1
);
-- =====================================================================
-- The ledger
-- =====================================================================
create table if not exists app.transactions (
id uuid primary key default gen_random_uuid(),
-- Routing
shift_id uuid not null references app.shifts(id) on delete restrict,
shop_id uuid not null references app.shops(id) on delete restrict,
till_id uuid not null references app.tills(id) on delete restrict,
user_id uuid not null references auth.users(id) on delete restrict,
service_code text not null references app.services(code),
-- Identifiers
occurred_at timestamptz not null default now(),
reference_no bigint not null, -- per shop, sequential
external_ref text, -- OMT code, recharge id...
external_ref_provider text, -- 'OMT','ALFA','TOUCH','OGERO',...
status app.txn_status not null default 'completed',
-- Money (dual-currency on the same row; either side may be 0)
gross_usd numeric(14,2) not null default 0 check (gross_usd >= 0),
gross_lbp numeric(18,0) not null default 0 check (gross_lbp >= 0),
fee_usd numeric(14,2) not null default 0 check (fee_usd >= 0),
fee_lbp numeric(18,0) not null default 0 check (fee_lbp >= 0),
commission_usd numeric(14,2) not null default 0 check (commission_usd >= 0),
commission_lbp numeric(18,0) not null default 0 check (commission_lbp >= 0),
fx_rate_used numeric(18,4), -- USD/LBP at moment of txn
payment_method app.payment_method not null,
-- Counterparty (used by various services; child tables hold the rest)
customer_id uuid, -- FK added in 0005 (KYC module)
beneficiary_name text,
beneficiary_phone text,
msisdn text,
operator text,
product_code text,
voucher_serial text,
notes text,
receipt_url text,
-- Audit
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id),
voided_at timestamptz,
voided_by uuid references auth.users(id),
void_reason text,
void_approved_by uuid references auth.users(id),
-- Hash chain (per shop)
row_hash bytea not null,
prev_row_hash bytea,
-- Constraints
constraint txn_unique_per_shop_ref unique (shop_id, reference_no),
constraint txn_unique_external_ref unique (external_ref_provider, external_ref),
constraint txn_void_consistency check (
(status = 'completed' and voided_at is null and voided_by is null and void_reason is null)
or (status = 'voided' and voided_at is not null and voided_by is not null and void_reason is not null)
)
);
create index if not exists idx_txn_shop_time on app.transactions(shop_id, occurred_at desc);
create index if not exists idx_txn_shift on app.transactions(shift_id, occurred_at);
create index if not exists idx_txn_user_time on app.transactions(user_id, occurred_at desc);
create index if not exists idx_txn_service on app.transactions(service_code, occurred_at desc);
create index if not exists idx_txn_status on app.transactions(status) where status = 'voided';
create index if not exists idx_txn_msisdn on app.transactions(msisdn) where msisdn is not null;
create index if not exists idx_txn_external on app.transactions(external_ref_provider, external_ref);
-- Now that transactions exists, attach the deferred FK from cash_movements.
alter table app.cash_movements
drop constraint if exists cash_movements_ref_txn_fk;
alter table app.cash_movements
add constraint cash_movements_ref_txn_fk
foreign key (ref_txn_id) references app.transactions(id) on delete restrict;
-- =====================================================================
-- Hash-chain helpers
-- =====================================================================
create or replace function app.txn_canonical_payload(t app.transactions)
returns text
language sql
immutable
as $$
select jsonb_build_object(
'id', t.id,
'shop_id', t.shop_id,
'till_id', t.till_id,
'shift_id', t.shift_id,
'user_id', t.user_id,
'service_code', t.service_code,
'occurred_at', to_char(t.occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MSOF'),
'reference_no', t.reference_no,
'external_ref', t.external_ref,
'external_ref_provider', t.external_ref_provider,
'status', t.status,
'gross_usd', t.gross_usd,
'gross_lbp', t.gross_lbp,
'fee_usd', t.fee_usd,
'fee_lbp', t.fee_lbp,
'commission_usd', t.commission_usd,
'commission_lbp', t.commission_lbp,
'fx_rate_used', t.fx_rate_used,
'payment_method', t.payment_method,
'customer_id', t.customer_id,
'beneficiary_name', t.beneficiary_name,
'beneficiary_phone', t.beneficiary_phone,
'msisdn', t.msisdn,
'operator', t.operator,
'product_code', t.product_code,
'voucher_serial', t.voucher_serial,
'notes', t.notes,
'receipt_url', t.receipt_url,
'created_by', t.created_by,
'voided_at', t.voided_at,
'voided_by', t.voided_by,
'void_reason', t.void_reason,
'void_approved_by', t.void_approved_by
)::text;
$$;
create or replace function app.txn_compute_hash(t app.transactions, prev bytea)
returns bytea
language sql
immutable
as $$
select digest(coalesce(prev, '\x'::bytea) || convert_to(app.txn_canonical_payload(t), 'UTF8'), 'sha256');
$$;
-- =====================================================================
-- Triggers — block raw writes; allow only what we sanction
-- =====================================================================
-- Block direct UPDATE/DELETE except when our SECURITY DEFINER void
-- function turns on the session GUC.
create or replace function app.txn_guard_update_delete()
returns trigger language plpgsql as $$
begin
if (tg_op = 'DELETE') then
raise exception 'transactions cannot be deleted';
end if;
if current_setting('app.txn_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on app.transactions is not allowed; use app.void_transaction';
end if;
-- Even via the void path, only the void/status fields may change.
if (new.id <> old.id
or new.shop_id <> old.shop_id
or new.till_id <> old.till_id
or new.shift_id <> old.shift_id
or new.user_id <> old.user_id
or new.service_code <> old.service_code
or new.occurred_at <> old.occurred_at
or new.reference_no <> old.reference_no
or coalesce(new.external_ref,'') <> coalesce(old.external_ref,'')
or coalesce(new.external_ref_provider,'') <> coalesce(old.external_ref_provider,'')
or new.gross_usd <> old.gross_usd
or new.gross_lbp <> old.gross_lbp
or new.fee_usd <> old.fee_usd
or new.fee_lbp <> old.fee_lbp
or new.commission_usd <> old.commission_usd
or new.commission_lbp <> old.commission_lbp
or coalesce(new.fx_rate_used, -1) <> coalesce(old.fx_rate_used, -1)
or new.payment_method <> old.payment_method
or new.created_by <> old.created_by
or new.created_at <> old.created_at) then
raise exception 'only status/void fields may change on a transaction';
end if;
return new;
end;
$$;
drop trigger if exists trg_txn_guard_update on app.transactions;
create trigger trg_txn_guard_update
before update on app.transactions
for each row execute function app.txn_guard_update_delete();
drop trigger if exists trg_txn_guard_delete on app.transactions;
create trigger trg_txn_guard_delete
before delete on app.transactions
for each row execute function app.txn_guard_update_delete();
-- Server stamping + hash chain on insert.
create or replace function app.txn_before_insert()
returns trigger
language plpgsql
as $$
declare
v_prev_hash bytea;
v_seq bigint;
v_shift app.shifts%rowtype;
begin
-- Caller identity / time are server-controlled.
new.created_by := auth.uid();
new.created_at := now();
new.occurred_at := now();
new.status := 'completed';
new.voided_at := null;
new.voided_by := null;
new.void_reason := null;
new.void_approved_by := null;
-- Shift must be open and owned by the caller; shop/till derived from it.
select * into v_shift from app.shifts where id = new.shift_id;
if v_shift.id is null then
raise exception 'shift % not found', new.shift_id;
end if;
if v_shift.status <> 'open' then
raise exception 'cannot post a transaction to a % shift', v_shift.status;
end if;
if v_shift.user_id <> auth.uid() then
raise exception 'only the shift owner may post transactions to it';
end if;
new.shop_id := v_shift.shop_id;
new.till_id := v_shift.till_id;
new.user_id := v_shift.user_id;
-- Allocate the shop's next reference number (advisory lock keeps it
-- gap-free under concurrency).
perform pg_advisory_xact_lock(hashtext('shop_seq:' || new.shop_id::text));
insert into app.shop_sequences(shop_id, next_value)
values (new.shop_id, 1)
on conflict (shop_id) do nothing;
update app.shop_sequences
set next_value = next_value + 1
where shop_id = new.shop_id
returning next_value - 1 into v_seq;
new.reference_no := v_seq;
-- Compute hash linking to previous row in this shop.
select row_hash into v_prev_hash
from app.transactions
where shop_id = new.shop_id
order by reference_no desc
limit 1;
new.prev_row_hash := v_prev_hash;
new.row_hash := app.txn_compute_hash(new, v_prev_hash);
return new;
end;
$$;
drop trigger if exists trg_txn_before_insert on app.transactions;
create trigger trg_txn_before_insert
before insert on app.transactions
for each row execute function app.txn_before_insert();
-- =====================================================================
-- Void
-- =====================================================================
-- Configurable self-service void window (minutes).
create table if not exists app.system_settings (
key text primary key,
value text not null
);
insert into app.system_settings(key, value)
values ('void_self_window_minutes', '10')
on conflict (key) do nothing;
create or replace function app.void_transaction(
p_txn_id uuid,
p_reason text,
p_approver_pin text default null -- required for manager approval path
)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
s app.shifts%rowtype;
window_min int;
needs_manager boolean;
begin
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'transaction not found'; end if;
if t.status = 'voided' then raise exception 'transaction already voided'; end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'a reason of at least 5 characters is required';
end if;
select * into s from app.shifts where id = t.shift_id;
if s.status <> 'open' then
raise exception 'cannot void a transaction whose shift is no longer open';
end if;
select coalesce(value::int, 10) into window_min
from app.system_settings where key = 'void_self_window_minutes';
needs_manager := (auth.uid() <> t.user_id)
or (now() - t.created_at > make_interval(mins => window_min));
if needs_manager then
-- Caller must be a manager in this shop AND prove it with PIN.
if not app.has_role_in_shop(t.shop_id, 'manager') then
raise exception 'manager approval required to void this transaction';
end if;
if p_approver_pin is null or not app.verify_my_pin(p_approver_pin) then
raise exception 'manager PIN required and must be valid';
end if;
end if;
-- Apply the void (only allowed via this function thanks to the guard).
perform set_config('app.txn_internal', 'on', true);
update app.transactions
set status = 'voided',
voided_at = now(),
voided_by = auth.uid(),
void_reason = p_reason,
void_approved_by = case when needs_manager then auth.uid() else null end
where id = p_txn_id;
perform set_config('app.txn_internal', 'off', true);
-- Recompute the row's hash so the chain reflects the new state.
perform set_config('app.txn_internal', 'on', true);
update app.transactions tt
set row_hash = app.txn_compute_hash(tt, tt.prev_row_hash)
where id = p_txn_id;
perform set_config('app.txn_internal', 'off', true);
perform app.log_auth_event('txn_voided', t.shop_id, null,
jsonb_build_object('txn_id', p_txn_id, 'manager_path', needs_manager));
end;
$$;
revoke all on function app.void_transaction(uuid, text, text) from public;
grant execute on function app.void_transaction(uuid, text, text) to authenticated;
-- =====================================================================
-- Daily integrity checks (callable by an owner cron)
-- =====================================================================
create or replace function app.verify_chain(p_shop uuid)
returns table (txn_id uuid, reference_no bigint, ok boolean)
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare prev bytea;
rec app.transactions%rowtype;
begin
if not app.has_role_in_shop(p_shop, 'owner')
and not app.has_role_in_shop(p_shop, 'auditor') then
raise exception 'not authorized';
end if;
prev := null;
for rec in
select * from app.transactions
where shop_id = p_shop
order by reference_no
loop
txn_id := rec.id;
reference_no := rec.reference_no;
ok := (rec.prev_row_hash is not distinct from prev)
and (rec.row_hash = app.txn_compute_hash(rec, prev));
prev := rec.row_hash;
return next;
end loop;
end;
$$;
revoke all on function app.verify_chain(uuid) from public;
grant execute on function app.verify_chain(uuid) to authenticated;
-- Reference-number gap detector
create or replace view app.v_reference_gaps as
select shop_id,
reference_no + 1 as gap_starts_at,
next_ref - 1 as gap_ends_at
from (
select shop_id, reference_no,
lead(reference_no) over (partition by shop_id order by reference_no) as next_ref
from app.transactions
) s
where next_ref is not null and next_ref <> reference_no + 1;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.transactions enable row level security;
alter table app.transactions force row level security;
alter table app.services enable row level security;
alter table app.services force row level security;
alter table app.shop_sequences enable row level security;
alter table app.shop_sequences force row level security;
alter table app.system_settings enable row level security;
alter table app.system_settings force row level security;
-- Direct UPDATE/DELETE blocked by triggers, but also revoke at SQL level.
revoke update, delete on app.transactions from authenticated;
revoke insert, update, delete on app.shop_sequences from authenticated;
revoke insert, update, delete on app.system_settings from authenticated;
drop policy if exists txn_select on app.transactions;
create policy txn_select on app.transactions
for select to authenticated
using (
user_id = auth.uid()
or app.has_any_role_in_shop(shop_id, array['owner','manager','auditor']::app.business_role[])
);
drop policy if exists txn_insert_in_open_shift on app.transactions;
create policy txn_insert_in_open_shift on app.transactions
for insert to authenticated
with check (
exists (
select 1 from app.shifts s
where s.id = transactions.shift_id
and s.status = 'open'
and s.user_id = auth.uid()
)
);
-- Services: readable by all authenticated users; only owners may modify
-- (via direct grants kept off, future migration will add a function).
drop policy if exists services_select on app.services;
create policy services_select on app.services
for select to authenticated using (true);
-- Shop sequences and system settings: readable by owner/auditor.
drop policy if exists shop_seq_select on app.shop_sequences;
create policy shop_seq_select on app.shop_sequences
for select to authenticated
using (app.has_any_role_in_shop(shop_id, array['owner','auditor']::app.business_role[]));
drop policy if exists settings_select on app.system_settings;
create policy settings_select on app.system_settings
for select to authenticated using (true);
grant select, insert on app.transactions to authenticated;
grant select on app.services to authenticated;
grant select on app.shop_sequences to authenticated;
grant select on app.system_settings to authenticated;
-- =====================================================================
-- Seed services
-- =====================================================================
insert into app.services(code, name, category) values
('OMT_SEND', 'OMT Send', 'transfer'),
('OMT_RECEIVE', 'OMT Receive/Payout', 'transfer'),
('OMT_BILL', 'OMT Bill Payment', 'bill'),
('WU_SEND', 'Western Union Send', 'transfer'),
('WU_RECEIVE', 'Western Union Pay', 'transfer'),
('ALFA_RECHARGE', 'Alfa Recharge', 'recharge'),
('TOUCH_RECHARGE', 'touch Recharge', 'recharge'),
('OGERO_RECHARGE', 'Ogero Recharge', 'recharge'),
('INTERNET_RECHARGE','Internet Recharge', 'recharge'),
('SIM_SALE', 'SIM Sale', 'goods'),
('PHONE_SALE', 'Phone Sale', 'goods'),
('ACCESSORY_SALE', 'Accessory Sale', 'goods'),
('REPAIR', 'Repair Service', 'service')
on conflict (code) do nothing;
-- End migration 0003 ----------------------------------------------------
@@ -0,0 +1,388 @@
-- =====================================================================
-- Migration 0004 — Service-specific detail tables (roadmap Step 5).
--
-- Each child row is 1-to-1 with a row in app.transactions and is
-- mandatory for its service. A check trigger blocks completing a txn of
-- a given service without the matching detail row.
--
-- Threat-model rows addressed: 1, 3, 5, 16, 19, 21.
-- =====================================================================
-- ---------------------------------------------------------------------
-- Common helpers
-- ---------------------------------------------------------------------
do $$ begin
create type app.id_doc_type as enum (
'lebanese_id', 'passport', 'residence_permit', 'driver_license', 'other'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.transfer_direction as enum ('domestic', 'international');
exception when duplicate_object then null; end $$;
-- A small helper used by all child triggers: the parent txn must exist,
-- be 'completed' (we attach detail at insert time only), and match the
-- expected service_code.
create or replace function app._require_txn_service(p_txn uuid, p_service text)
returns void
language plpgsql
stable
as $$
declare svc text; st app.txn_status;
begin
select service_code, status into svc, st
from app.transactions where id = p_txn;
if svc is null then raise exception 'transaction % not found', p_txn; end if;
if svc <> p_service then
raise exception 'detail mismatch: txn service is % but detail row is for %',
svc, p_service;
end if;
if st <> 'completed' then
raise exception 'cannot attach detail to a % transaction', st;
end if;
end;
$$;
-- =====================================================================
-- OMT — Send
-- =====================================================================
create table if not exists app.omt_send_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
direction app.transfer_direction not null,
sender_full_name text not null,
sender_id_type app.id_doc_type not null,
sender_id_number text not null,
sender_phone text not null,
sender_dob date,
sender_nationality text,
beneficiary_full_name text not null,
beneficiary_phone text,
destination_country text, -- ISO-3166 alpha-2 expected for international
purpose_code text not null, -- 'family_support','salary','goods','services',...
purpose_note text,
kyc_doc_url text, -- ID photo / declaration
created_at timestamptz not null default now(),
constraint omt_send_intl_country_required check (
direction = 'domestic' or destination_country is not null
),
constraint omt_send_id_format check (length(btrim(sender_id_number)) >= 4)
);
-- =====================================================================
-- OMT — Receive / Payout
-- =====================================================================
create table if not exists app.omt_receive_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
payout_code text not null, -- the customer-presented code
beneficiary_full_name text not null,
beneficiary_id_type app.id_doc_type not null,
beneficiary_id_number text not null,
beneficiary_phone text,
origin_country text,
kyc_doc_url text,
created_at timestamptz not null default now(),
constraint omt_recv_id_format check (length(btrim(beneficiary_id_number)) >= 4),
constraint omt_recv_code_format check (length(btrim(payout_code)) >= 6)
);
-- =====================================================================
-- Bill payment (EDL, water, internet bills, gov fees, etc.)
-- =====================================================================
create table if not exists app.bill_payment_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
biller_code text not null, -- 'EDL','OGERO','MOF','NSSF',...
account_number text not null,
period text, -- '2026-04', invoice id, etc.
customer_name text,
created_at timestamptz not null default now(),
constraint bill_account_format check (length(btrim(account_number)) >= 3)
);
-- =====================================================================
-- Recharge (Alfa / touch / Ogero / Internet)
-- Either a voucher_serial (physical scratch card) OR an
-- e_recharge_provider_ref (provider confirmation id) is mandatory.
-- =====================================================================
create table if not exists app.recharge_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
operator text not null, -- 'ALFA','TOUCH','OGERO','IDM','CYBERIA','TERRANET'
msisdn text not null, -- subscriber number being recharged
product_code text not null, -- 'U-CARD-22USD','MAGIC-11USD','DATA-5GB',...
voucher_serial text, -- if scratch card
e_recharge_provider_ref text, -- if e-recharge
unit_face_value_usd numeric(14,2),
unit_cost_usd numeric(14,2), -- cost to shop (margin = price - cost)
created_at timestamptz not null default now(),
constraint recharge_msisdn_format check (msisdn ~ '^\+?\d{6,15}$'),
constraint recharge_must_have_evidence check (
(voucher_serial is not null) or (e_recharge_provider_ref is not null)
)
);
-- A given voucher serial may only ever be sold once across the whole
-- system (vector #4: skim a card, claim "lost").
create unique index if not exists uq_recharge_voucher_serial
on app.recharge_details(voucher_serial)
where voucher_serial is not null;
-- An e-recharge provider reference is unique per operator.
create unique index if not exists uq_recharge_provider_ref
on app.recharge_details(operator, e_recharge_provider_ref)
where e_recharge_provider_ref is not null;
create index if not exists idx_recharge_msisdn on app.recharge_details(msisdn);
create index if not exists idx_recharge_operator on app.recharge_details(operator);
-- =====================================================================
-- Goods sale (SIM / phone / accessory) and repair
-- Real inventory FK comes in 0006; for now we capture sku + qty.
-- =====================================================================
create table if not exists app.goods_sale_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
sku text not null,
qty integer not null check (qty > 0),
unit_cost_usd numeric(14,2) not null check (unit_cost_usd >= 0),
unit_price_usd numeric(14,2) not null check (unit_price_usd >= 0),
serial_number text, -- IMEI for phones
created_at timestamptz not null default now()
);
create table if not exists app.repair_details (
txn_id uuid primary key references app.transactions(id) on delete restrict,
device_type text not null,
device_imei text,
issue_summary text not null,
warranty_days integer not null default 0 check (warranty_days >= 0),
created_at timestamptz not null default now()
);
-- =====================================================================
-- Triggers — service consistency + append-only on details
-- =====================================================================
create or replace function app._detail_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'transaction detail rows are append-only'; end;
$$;
-- per-table: stamp + service-code check + immutability
create or replace function app.omt_send_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'OMT_SEND'); return new; end;
$$;
drop trigger if exists trg_omt_send_check on app.omt_send_details;
create trigger trg_omt_send_check before insert on app.omt_send_details
for each row execute function app.omt_send_check();
drop trigger if exists trg_omt_send_freeze on app.omt_send_details;
create trigger trg_omt_send_freeze before update or delete on app.omt_send_details
for each row execute function app._detail_no_update_delete();
create or replace function app.omt_recv_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'OMT_RECEIVE'); return new; end;
$$;
drop trigger if exists trg_omt_recv_check on app.omt_receive_details;
create trigger trg_omt_recv_check before insert on app.omt_receive_details
for each row execute function app.omt_recv_check();
drop trigger if exists trg_omt_recv_freeze on app.omt_receive_details;
create trigger trg_omt_recv_freeze before update or delete on app.omt_receive_details
for each row execute function app._detail_no_update_delete();
create or replace function app.bill_pay_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'OMT_BILL'); return new; end;
$$;
drop trigger if exists trg_bill_pay_check on app.bill_payment_details;
create trigger trg_bill_pay_check before insert on app.bill_payment_details
for each row execute function app.bill_pay_check();
drop trigger if exists trg_bill_pay_freeze on app.bill_payment_details;
create trigger trg_bill_pay_freeze before update or delete on app.bill_payment_details
for each row execute function app._detail_no_update_delete();
-- Recharge: any of the four recharge service codes is acceptable.
create or replace function app.recharge_check()
returns trigger language plpgsql as $$
declare svc text;
begin
select service_code into svc from app.transactions where id = new.txn_id;
if svc not in ('ALFA_RECHARGE','TOUCH_RECHARGE','OGERO_RECHARGE','INTERNET_RECHARGE') then
raise exception 'recharge_details only valid for recharge services (got %)', svc;
end if;
return new;
end;
$$;
drop trigger if exists trg_recharge_check on app.recharge_details;
create trigger trg_recharge_check before insert on app.recharge_details
for each row execute function app.recharge_check();
drop trigger if exists trg_recharge_freeze on app.recharge_details;
create trigger trg_recharge_freeze before update or delete on app.recharge_details
for each row execute function app._detail_no_update_delete();
create or replace function app.goods_sale_check()
returns trigger language plpgsql as $$
declare svc text;
begin
select service_code into svc from app.transactions where id = new.txn_id;
if svc not in ('SIM_SALE','PHONE_SALE','ACCESSORY_SALE') then
raise exception 'goods_sale_details only valid for goods services (got %)', svc;
end if;
return new;
end;
$$;
drop trigger if exists trg_goods_sale_check on app.goods_sale_details;
create trigger trg_goods_sale_check before insert on app.goods_sale_details
for each row execute function app.goods_sale_check();
drop trigger if exists trg_goods_sale_freeze on app.goods_sale_details;
create trigger trg_goods_sale_freeze before update or delete on app.goods_sale_details
for each row execute function app._detail_no_update_delete();
create or replace function app.repair_check()
returns trigger language plpgsql as $$
begin perform app._require_txn_service(new.txn_id, 'REPAIR'); return new; end;
$$;
drop trigger if exists trg_repair_check on app.repair_details;
create trigger trg_repair_check before insert on app.repair_details
for each row execute function app.repair_check();
drop trigger if exists trg_repair_freeze on app.repair_details;
create trigger trg_repair_freeze before update or delete on app.repair_details
for each row execute function app._detail_no_update_delete();
-- =====================================================================
-- Cross-row check: a completed transaction must have its matching
-- detail row. Implemented as a deferred constraint trigger that fires
-- at COMMIT time on app.transactions, so client code can do
-- BEGIN; INSERT txn; INSERT detail; COMMIT;
-- =====================================================================
create or replace function app.txn_require_detail()
returns trigger
language plpgsql
as $$
declare ok boolean;
begin
if new.status <> 'completed' then return null; end if;
case new.service_code
when 'OMT_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
when 'OMT_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
when 'OMT_BILL' then select exists(select 1 from app.bill_payment_details where txn_id = new.id) into ok;
when 'WU_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
when 'WU_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
when 'ALFA_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'TOUCH_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'OGERO_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'INTERNET_RECHARGE'then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'SIM_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'PHONE_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'ACCESSORY_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'REPAIR' then select exists(select 1 from app.repair_details where txn_id = new.id) into ok;
else ok := true; -- unknown / future services: allow until a child is added
end case;
if not ok then
raise exception 'transaction % (service %) is missing its detail row',
new.id, new.service_code;
end if;
return null;
end;
$$;
drop trigger if exists trg_txn_require_detail on app.transactions;
create constraint trigger trg_txn_require_detail
after insert on app.transactions
deferrable initially deferred
for each row execute function app.txn_require_detail();
-- =====================================================================
-- RLS — visibility follows the parent transaction.
-- =====================================================================
alter table app.omt_send_details enable row level security;
alter table app.omt_receive_details enable row level security;
alter table app.bill_payment_details enable row level security;
alter table app.recharge_details enable row level security;
alter table app.goods_sale_details enable row level security;
alter table app.repair_details enable row level security;
alter table app.omt_send_details force row level security;
alter table app.omt_receive_details force row level security;
alter table app.bill_payment_details force row level security;
alter table app.recharge_details force row level security;
alter table app.goods_sale_details force row level security;
alter table app.repair_details force row level security;
revoke update, delete on
app.omt_send_details, app.omt_receive_details, app.bill_payment_details,
app.recharge_details, app.goods_sale_details, app.repair_details
from authenticated;
-- Helper: visibility predicate based on parent txn.
create or replace function app._can_see_txn(p_txn uuid)
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1 from app.transactions t
where t.id = p_txn
and (
t.user_id = auth.uid()
or app.has_any_role_in_shop(t.shop_id,
array['owner','manager','auditor']::app.business_role[])
)
);
$$;
revoke all on function app._can_see_txn(uuid) from public;
grant execute on function app._can_see_txn(uuid) to authenticated;
-- Insert allowed if the user owns the parent txn's open shift.
create or replace function app._can_write_detail(p_txn uuid)
returns boolean
language sql
security definer
set search_path = app, public
stable
as $$
select exists (
select 1 from app.transactions t
join app.shifts s on s.id = t.shift_id
where t.id = p_txn
and t.user_id = auth.uid()
and s.status = 'open'
);
$$;
revoke all on function app._can_write_detail(uuid) from public;
grant execute on function app._can_write_detail(uuid) to authenticated;
-- Apply identical select/insert policies to all six child tables.
do $$
declare tbl text;
begin
foreach tbl in array array[
'omt_send_details','omt_receive_details','bill_payment_details',
'recharge_details','goods_sale_details','repair_details'
] loop
execute format('drop policy if exists %I_select on app.%I;', tbl, tbl);
execute format($p$
create policy %I_select on app.%I
for select to authenticated
using (app._can_see_txn(txn_id));
$p$, tbl, tbl);
execute format('drop policy if exists %I_insert on app.%I;', tbl, tbl);
execute format($p$
create policy %I_insert on app.%I
for insert to authenticated
with check (app._can_write_detail(txn_id));
$p$, tbl, tbl);
execute format('grant select, insert on app.%I to authenticated;', tbl);
end loop;
end $$;
-- End migration 0004 ----------------------------------------------------
@@ -0,0 +1,618 @@
-- =====================================================================
-- Migration 0005 — Inventory and e-float (roadmap Step 6).
--
-- Two parallel stock systems for a cell shop:
--
-- 1. Physical inventory: scratch cards (with serials), SIMs, phones,
-- accessories. Voucher serials track per-card lifecycle so the
-- same card can never be sold twice and "lost" cards are visible.
--
-- 2. Electronic float: OMT cash float, Alfa/touch e-recharge wallet,
-- whish, etc. Every recharge or transfer must move e-float in
-- lockstep with cash, otherwise reconciliation fails.
--
-- Threat-model rows addressed: 3, 4, 5, 13, 21, 22.
-- =====================================================================
-- =====================================================================
-- Items and physical stock
-- =====================================================================
do $$ begin
create type app.item_type as enum (
'scratch_card', 'sim', 'phone', 'accessory', 'consumable'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.stock_movement_type as enum (
'purchase_in', -- received from distributor
'sale_out', -- linked to a transaction
'return_in', -- customer return
'damaged_out', -- write-off (manager approval)
'lost_out', -- write-off (manager approval)
'transfer_in', -- between shops
'transfer_out',
'adjustment_in', -- audited correction
'adjustment_out'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.voucher_status as enum (
'in_stock', 'sold', 'damaged', 'lost', 'returned'
);
exception when duplicate_object then null; end $$;
create table if not exists app.items (
sku text primary key,
name text not null,
type app.item_type not null,
operator text, -- 'ALFA','TOUCH', null for non-recharge
face_value_usd numeric(14,2), -- recharge denomination if applicable
cost_usd numeric(14,2) not null check (cost_usd >= 0),
price_usd numeric(14,2) not null check (price_usd >= 0),
is_active boolean not null default true,
created_at timestamptz not null default now()
);
-- Per-shop stock-on-hand counter (denormalized, kept in sync by trigger).
create table if not exists app.stock_on_hand (
sku text not null references app.items(sku),
shop_id uuid not null references app.shops(id),
qty integer not null default 0 check (qty >= 0),
primary key (sku, shop_id)
);
create table if not exists app.stock_lots (
id uuid primary key default gen_random_uuid(),
sku text not null references app.items(sku),
shop_id uuid not null references app.shops(id),
received_at timestamptz not null default now(),
qty_received integer not null check (qty_received > 0),
unit_cost_usd numeric(14,2) not null check (unit_cost_usd >= 0),
supplier text,
invoice_no text,
received_by uuid not null references auth.users(id) default auth.uid(),
created_at timestamptz not null default now()
);
create index if not exists idx_stock_lots_sku_shop on app.stock_lots(sku, shop_id);
-- Append-only stock movements ledger -----------------------------------
create table if not exists app.stock_movements (
id uuid primary key default gen_random_uuid(),
sku text not null references app.items(sku),
shop_id uuid not null references app.shops(id),
shift_id uuid references app.shifts(id),
type app.stock_movement_type not null,
-- Signed: positive = +stock (purchase_in, return_in, transfer_in, adjustment_in)
-- negative = -stock (sale_out, damaged_out, lost_out, transfer_out, adjustment_out)
qty_delta integer not null check (qty_delta <> 0),
ref_txn_id uuid references app.transactions(id),
ref_lot_id uuid references app.stock_lots(id),
approved_by uuid references auth.users(id), -- required for damaged/lost/adjustment
reason text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_stock_mov_sku_shop on app.stock_movements(sku, shop_id, created_at desc);
create index if not exists idx_stock_mov_txn on app.stock_movements(ref_txn_id);
-- =====================================================================
-- Voucher inventory (per-serial lifecycle)
-- =====================================================================
create table if not exists app.voucher_inventory (
serial text primary key,
sku text not null references app.items(sku),
shop_id uuid not null references app.shops(id),
lot_id uuid references app.stock_lots(id),
status app.voucher_status not null default 'in_stock',
received_at timestamptz not null default now(),
sold_txn_id uuid references app.transactions(id),
sold_at timestamptz,
status_changed_by uuid references auth.users(id),
status_change_reason text,
constraint voucher_status_consistency check (
(status = 'in_stock' and sold_txn_id is null and sold_at is null)
or (status = 'sold' and sold_txn_id is not null and sold_at is not null)
or (status in ('damaged','lost','returned')
and sold_txn_id is null and sold_at is null)
)
);
create index if not exists idx_voucher_status on app.voucher_inventory(status);
create index if not exists idx_voucher_sku_shop on app.voucher_inventory(sku, shop_id);
-- =====================================================================
-- E-float (OMT cash float, Alfa e-recharge wallet, etc.)
-- =====================================================================
do $$ begin
create type app.float_provider as enum (
'OMT_CASH', 'OMT_DIGITAL', 'ALFA_ERECHARGE', 'TOUCH_ERECHARGE',
'OGERO_ERECHARGE', 'WHISH', 'CARD_TERMINAL', 'BANK'
);
exception when duplicate_object then null; end $$;
create table if not exists app.floats (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
provider app.float_provider not null,
currency app.currency_code not null,
is_active boolean not null default true,
created_at timestamptz not null default now(),
unique (shop_id, provider, currency)
);
-- Cached balance per float, kept in sync by the movements trigger.
create table if not exists app.float_balances (
float_id uuid primary key references app.floats(id) on delete cascade,
balance numeric(20,2) not null default 0,
updated_at timestamptz not null default now()
);
create table if not exists app.float_movements (
id uuid primary key default gen_random_uuid(),
float_id uuid not null references app.floats(id) on delete restrict,
shift_id uuid references app.shifts(id),
occurred_at timestamptz not null default now(),
-- Signed: + adds to e-float, - removes from it.
amount numeric(20,2) not null check (amount <> 0),
ref_txn_id uuid references app.transactions(id),
ref_settlement_id uuid, -- FK added in 0007
reason text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_float_mov_float on app.float_movements(float_id, occurred_at);
create index if not exists idx_float_mov_txn on app.float_movements(ref_txn_id);
-- =====================================================================
-- Triggers — append-only, balance maintenance, no negative stock
-- =====================================================================
-- Stock movements: append-only.
create or replace function app._stock_mov_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'stock_movements is append-only'; end;
$$;
drop trigger if exists trg_stock_mov_freeze on app.stock_movements;
create trigger trg_stock_mov_freeze before update or delete on app.stock_movements
for each row execute function app._stock_mov_no_update_delete();
-- Stock movements: server-stamped, sign matches type, optional manager
-- approval enforced for write-offs.
create or replace function app._stock_mov_before_insert()
returns trigger language plpgsql as $$
begin
new.created_at := now();
new.created_by := auth.uid();
-- Sign / type consistency.
if new.type in ('purchase_in','return_in','transfer_in','adjustment_in')
and new.qty_delta <= 0 then
raise exception '% must have qty_delta > 0', new.type;
end if;
if new.type in ('sale_out','damaged_out','lost_out','transfer_out','adjustment_out')
and new.qty_delta >= 0 then
raise exception '% must have qty_delta < 0', new.type;
end if;
-- Write-offs and adjustments need manager approval.
if new.type in ('damaged_out','lost_out','adjustment_in','adjustment_out')
and new.approved_by is null then
raise exception '% requires manager approval (approved_by)', new.type;
end if;
-- sale_out must reference a real, completed sale of the same shop.
if new.type = 'sale_out' then
if new.ref_txn_id is null then
raise exception 'sale_out requires ref_txn_id';
end if;
if not exists (
select 1 from app.transactions
where id = new.ref_txn_id and shop_id = new.shop_id and status = 'completed'
) then
raise exception 'sale_out must reference a completed txn in the same shop';
end if;
end if;
return new;
end;
$$;
drop trigger if exists trg_stock_mov_before_insert on app.stock_movements;
create trigger trg_stock_mov_before_insert before insert on app.stock_movements
for each row execute function app._stock_mov_before_insert();
-- Maintain stock_on_hand. No negative balance allowed.
create or replace function app._stock_on_hand_apply()
returns trigger language plpgsql as $$
begin
insert into app.stock_on_hand(sku, shop_id, qty)
values (new.sku, new.shop_id, new.qty_delta)
on conflict (sku, shop_id) do update
set qty = app.stock_on_hand.qty + new.qty_delta;
-- Re-check; the CHECK on the table will already reject negatives but
-- give a clearer error here.
if (select qty from app.stock_on_hand
where sku = new.sku and shop_id = new.shop_id) < 0 then
raise exception 'stock would go negative for sku=% shop=%', new.sku, new.shop_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_stock_on_hand_apply on app.stock_movements;
create trigger trg_stock_on_hand_apply after insert on app.stock_movements
for each row execute function app._stock_on_hand_apply();
-- Stock_lots: receiving stock auto-creates a purchase_in movement.
create or replace function app._stock_lot_after_insert()
returns trigger language plpgsql as $$
begin
insert into app.stock_movements(sku, shop_id, type, qty_delta, ref_lot_id, reason)
values (new.sku, new.shop_id, 'purchase_in', new.qty_received, new.id,
coalesce('lot ' || new.invoice_no, 'lot received'));
return null;
end;
$$;
drop trigger if exists trg_stock_lot_after_insert on app.stock_lots;
create trigger trg_stock_lot_after_insert after insert on app.stock_lots
for each row execute function app._stock_lot_after_insert();
-- Float movements: append-only + balance.
create or replace function app._float_mov_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'float_movements is append-only'; end;
$$;
drop trigger if exists trg_float_mov_freeze on app.float_movements;
create trigger trg_float_mov_freeze before update or delete on app.float_movements
for each row execute function app._float_mov_no_update_delete();
create or replace function app._float_balance_apply()
returns trigger language plpgsql as $$
begin
insert into app.float_balances(float_id, balance, updated_at)
values (new.float_id, new.amount, now())
on conflict (float_id) do update
set balance = app.float_balances.balance + new.amount,
updated_at = now();
if (select balance from app.float_balances where float_id = new.float_id) < 0 then
raise exception 'float would go negative for float_id=%', new.float_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_float_balance_apply on app.float_movements;
create trigger trg_float_balance_apply after insert on app.float_movements
for each row execute function app._float_balance_apply();
-- =====================================================================
-- Recharge ↔ stock/float coupling
-- A recharge_details row MUST move either physical stock (voucher) or
-- e-float, otherwise it is a free recharge — exactly the fraud we want
-- to make impossible (vectors #3, #21).
-- Implemented as a deferred constraint trigger so the client can write
-- the recharge row first, then the movement, in a single transaction.
-- =====================================================================
create or replace function app._recharge_require_movement()
returns trigger
language plpgsql
as $$
declare
has_voucher_movement boolean;
has_float_movement boolean;
v_provider app.float_provider;
begin
if new.voucher_serial is not null then
-- The voucher must be marked sold and tied to this txn.
select exists(
select 1 from app.voucher_inventory
where serial = new.voucher_serial
and status = 'sold'
and sold_txn_id = new.txn_id
) into has_voucher_movement;
if not has_voucher_movement then
raise exception
'recharge with voucher_serial=% must be paired with a sold voucher',
new.voucher_serial;
end if;
else
-- E-recharge: an e-float debit must exist for this txn against the
-- matching operator's e-float account.
v_provider := case new.operator
when 'ALFA' then 'ALFA_ERECHARGE'::app.float_provider
when 'TOUCH' then 'TOUCH_ERECHARGE'::app.float_provider
when 'OGERO' then 'OGERO_ERECHARGE'::app.float_provider
else null
end;
if v_provider is null then
-- Unmapped operator (IDM, CYBERIA, TERRANET): require any negative
-- float movement for this txn.
select exists(
select 1 from app.float_movements
where ref_txn_id = new.txn_id and amount < 0
) into has_float_movement;
else
select exists(
select 1
from app.float_movements fm
join app.floats f on f.id = fm.float_id
join app.transactions t on t.id = fm.ref_txn_id
where fm.ref_txn_id = new.txn_id
and fm.amount < 0
and f.provider = v_provider
and f.shop_id = t.shop_id
) into has_float_movement;
end if;
if not has_float_movement then
raise exception
'e-recharge txn % must be paired with a negative e-float movement',
new.txn_id;
end if;
end if;
return null;
end;
$$;
drop trigger if exists trg_recharge_require_movement on app.recharge_details;
create constraint trigger trg_recharge_require_movement
after insert on app.recharge_details
deferrable initially deferred
for each row execute function app._recharge_require_movement();
-- Goods sale ↔ stock_movement coupling (same idea).
create or replace function app._goods_sale_require_movement()
returns trigger
language plpgsql
as $$
declare ok boolean;
begin
select exists(
select 1 from app.stock_movements sm
where sm.ref_txn_id = new.txn_id
and sm.sku = new.sku
and sm.type = 'sale_out'
and -sm.qty_delta = new.qty
) into ok;
if not ok then
raise exception 'goods sale txn % must be paired with a sale_out stock movement', new.txn_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_goods_sale_require_movement on app.goods_sale_details;
create constraint trigger trg_goods_sale_require_movement
after insert on app.goods_sale_details
deferrable initially deferred
for each row execute function app._goods_sale_require_movement();
-- =====================================================================
-- SECURITY DEFINER helpers used by the cashier UI
-- =====================================================================
-- Sell a scratch card: marks the voucher sold + creates the stock_out.
-- Called inside the same transaction as inserting the txn + recharge_details.
create or replace function app.sell_voucher(
p_txn_id uuid,
p_serial text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare v app.voucher_inventory%rowtype;
t app.transactions%rowtype;
begin
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'txn not found'; end if;
if t.user_id <> auth.uid() then
raise exception 'only the txn owner may sell a voucher against it';
end if;
select * into v from app.voucher_inventory where serial = p_serial for update;
if v.serial is null then raise exception 'voucher % not found', p_serial; end if;
if v.shop_id <> t.shop_id then
raise exception 'voucher belongs to a different shop';
end if;
if v.status <> 'in_stock' then
raise exception 'voucher % is not in_stock (status=%)', p_serial, v.status;
end if;
update app.voucher_inventory
set status = 'sold', sold_txn_id = p_txn_id, sold_at = now(),
status_changed_by = auth.uid()
where serial = p_serial;
insert into app.stock_movements(sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason)
values (v.sku, v.shop_id, t.shift_id, 'sale_out', -1, p_txn_id, 'voucher ' || p_serial);
end;
$$;
-- Mark a voucher damaged or lost (manager only, with PIN).
create or replace function app.write_off_voucher(
p_serial text,
p_status app.voucher_status,
p_reason text,
p_manager_pin text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare v app.voucher_inventory%rowtype;
begin
if p_status not in ('damaged','lost') then
raise exception 'only damaged/lost are valid write-off statuses';
end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'reason >= 5 chars required';
end if;
select * into v from app.voucher_inventory where serial = p_serial for update;
if v.serial is null then raise exception 'voucher not found'; end if;
if v.status <> 'in_stock' then
raise exception 'voucher must be in_stock to write off (was %)', v.status;
end if;
if not app.has_role_in_shop(v.shop_id, 'manager') then
raise exception 'manager role required';
end if;
if not app.verify_my_pin(p_manager_pin) then
raise exception 'invalid manager PIN';
end if;
update app.voucher_inventory
set status = p_status, status_changed_by = auth.uid(),
status_change_reason = p_reason
where serial = p_serial;
insert into app.stock_movements(sku, shop_id, type, qty_delta, approved_by, reason)
values (v.sku, v.shop_id,
case p_status when 'damaged' then 'damaged_out'::app.stock_movement_type
when 'lost' then 'lost_out'::app.stock_movement_type end,
-1, auth.uid(), p_reason);
end;
$$;
revoke all on function app.sell_voucher(uuid, text) from public;
revoke all on function app.write_off_voucher(text, app.voucher_status, text, text) from public;
grant execute on function app.sell_voucher(uuid, text) to authenticated;
grant execute on function app.write_off_voucher(text, app.voucher_status, text, text) to authenticated;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.items enable row level security;
alter table app.stock_on_hand enable row level security;
alter table app.stock_lots enable row level security;
alter table app.stock_movements enable row level security;
alter table app.voucher_inventory enable row level security;
alter table app.floats enable row level security;
alter table app.float_balances enable row level security;
alter table app.float_movements enable row level security;
alter table app.items force row level security;
alter table app.stock_on_hand force row level security;
alter table app.stock_lots force row level security;
alter table app.stock_movements force row level security;
alter table app.voucher_inventory force row level security;
alter table app.floats force row level security;
alter table app.float_balances force row level security;
alter table app.float_movements force row level security;
-- Block direct UPDATE/DELETE on append-only tables.
revoke update, delete on app.stock_movements from authenticated;
revoke update, delete on app.float_movements from authenticated;
revoke update, delete on app.voucher_inventory from authenticated;
revoke update, delete on app.stock_on_hand from authenticated;
revoke update, delete on app.float_balances from authenticated;
-- Items are reference data: only owners may modify (handled by policy).
-- Items: readable by everyone authenticated; writes for owners only.
drop policy if exists items_select on app.items;
create policy items_select on app.items for select to authenticated using (true);
drop policy if exists items_write_owner on app.items;
create policy items_write_owner on app.items
for all to authenticated
using (app.is_owner_anywhere())
with check (app.is_owner_anywhere());
-- Shop-scoped tables: readable to anyone assigned to the shop.
drop policy if exists soh_select on app.stock_on_hand;
create policy soh_select on app.stock_on_hand
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
);
drop policy if exists lots_select on app.stock_lots;
create policy lots_select on app.stock_lots
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','auditor']::app.business_role[])
);
drop policy if exists lots_insert on app.stock_lots;
create policy lots_insert on app.stock_lots
for insert to authenticated
with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
grant select, insert on app.stock_lots to authenticated;
drop policy if exists smov_select on app.stock_movements;
create policy smov_select on app.stock_movements
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
);
drop policy if exists smov_insert on app.stock_movements;
create policy smov_insert on app.stock_movements
for insert to authenticated
with check (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier']::app.business_role[])
);
grant select, insert on app.stock_movements to authenticated;
drop policy if exists vouch_select on app.voucher_inventory;
create policy vouch_select on app.voucher_inventory
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
);
drop policy if exists vouch_insert on app.voucher_inventory;
create policy vouch_insert on app.voucher_inventory
for insert to authenticated
with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
grant select, insert on app.voucher_inventory to authenticated;
-- Voucher status changes go through SECURITY DEFINER functions only.
drop policy if exists floats_select on app.floats;
create policy floats_select on app.floats
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
);
drop policy if exists floats_write_owner on app.floats;
create policy floats_write_owner on app.floats
for all to authenticated
using (app.has_role_in_shop(shop_id, 'owner'))
with check (app.has_role_in_shop(shop_id, 'owner'));
grant select, insert, update on app.floats to authenticated;
drop policy if exists fbal_select on app.float_balances;
create policy fbal_select on app.float_balances
for select to authenticated
using (
exists (
select 1 from app.floats f
where f.id = float_balances.float_id
and app.has_any_role_in_shop(f.shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
)
);
drop policy if exists fmov_select on app.float_movements;
create policy fmov_select on app.float_movements
for select to authenticated
using (
exists (
select 1 from app.floats f
where f.id = float_movements.float_id
and app.has_any_role_in_shop(f.shop_id,
array['owner','manager','cashier','auditor']::app.business_role[])
)
);
drop policy if exists fmov_insert on app.float_movements;
create policy fmov_insert on app.float_movements
for insert to authenticated
with check (
exists (
select 1 from app.floats f
where f.id = float_movements.float_id
and app.has_any_role_in_shop(f.shop_id,
array['owner','manager','cashier']::app.business_role[])
)
);
grant select, insert on app.float_movements to authenticated;
grant select on app.items, app.stock_on_hand, app.float_balances to authenticated;
-- End migration 0005 ----------------------------------------------------
@@ -0,0 +1,376 @@
-- =====================================================================
-- Migration 0006 — Customers, KYC, and AML controls (roadmap Step 7).
--
-- Builds the customer/KYC layer that backs OMT send/receive and any
-- transfer above thresholds. Aggregation views detect structuring
-- (splitting a large transfer across multiple smaller ones).
--
-- Threat-model rows addressed: 1, 5, 16.
-- =====================================================================
-- =====================================================================
-- Customers
-- =====================================================================
create table if not exists app.customers (
id uuid primary key default gen_random_uuid(),
full_name text not null,
id_type app.id_doc_type not null,
id_number text not null,
dob date,
nationality text,
phone text,
address text,
pep_flag boolean not null default false, -- politically exposed person
sanctions_hit boolean not null default false,
sanctions_checked_at timestamptz,
sanctions_source text, -- which list / API
notes text,
is_blocked boolean not null default false, -- owner can hard-block a customer
blocked_reason text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid(),
updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id),
constraint customers_id_unique unique (id_type, id_number)
);
create index if not exists idx_customers_phone on app.customers(phone);
create index if not exists idx_customers_name on app.customers(lower(full_name));
-- Stamp updated_*
create or replace function app._customers_stamp()
returns trigger language plpgsql as $$
begin
if tg_op = 'INSERT' then
new.created_by := auth.uid();
new.created_at := now();
end if;
new.updated_by := auth.uid();
new.updated_at := now();
return new;
end;
$$;
drop trigger if exists trg_customers_stamp on app.customers;
create trigger trg_customers_stamp
before insert or update on app.customers
for each row execute function app._customers_stamp();
-- KYC documents (ID photos, declarations) -----------------------------
create table if not exists app.customer_documents (
id uuid primary key default gen_random_uuid(),
customer_id uuid not null references app.customers(id) on delete restrict,
doc_type text not null, -- 'id_front','id_back','passport','declaration'
file_url text not null,
uploaded_at timestamptz not null default now(),
uploaded_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_customer_docs on app.customer_documents(customer_id);
-- Append-only customer-document table.
create or replace function app._customer_docs_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'customer_documents is append-only'; end;
$$;
drop trigger if exists trg_customer_docs_freeze on app.customer_documents;
create trigger trg_customer_docs_freeze before update or delete on app.customer_documents
for each row execute function app._customer_docs_no_update_delete();
-- =====================================================================
-- Now that customers exists, attach the deferred FK from transactions.
-- =====================================================================
alter table app.transactions
drop constraint if exists transactions_customer_fk;
alter table app.transactions
add constraint transactions_customer_fk
foreign key (customer_id) references app.customers(id) on delete restrict;
-- =====================================================================
-- KYC thresholds (per service / currency). Server-controlled.
-- A txn at or above `daily_amount_warn` requires a customer record;
-- at or above `daily_amount_block` it is hard-blocked unless an owner
-- override is on file.
-- =====================================================================
create table if not exists app.kyc_thresholds (
service_code text not null references app.services(code),
currency app.currency_code not null,
daily_amount_warn numeric(18,2) not null check (daily_amount_warn > 0),
daily_amount_block numeric(18,2) not null check (daily_amount_block > 0),
primary key (service_code, currency),
constraint kyc_thresholds_order check (daily_amount_block >= daily_amount_warn)
);
-- Sensible defaults. Owners can edit later.
insert into app.kyc_thresholds(service_code, currency, daily_amount_warn, daily_amount_block) values
('OMT_SEND', 'USD', 500, 10000),
('OMT_SEND', 'LBP', 45000000, 900000000),
('OMT_RECEIVE', 'USD', 500, 10000),
('OMT_RECEIVE', 'LBP', 45000000, 900000000),
('WU_SEND', 'USD', 500, 10000),
('WU_RECEIVE', 'USD', 500, 10000)
on conflict (service_code, currency) do nothing;
-- =====================================================================
-- Aggregation helper: customer's running daily total in a service
-- across the network (all shops).
-- =====================================================================
create or replace function app.customer_daily_total(
p_customer uuid,
p_service text,
p_currency app.currency_code,
p_at timestamptz default now()
) returns numeric
language sql
security definer
set search_path = app, public
stable
as $$
select coalesce(sum(
case when p_currency = 'USD' then t.gross_usd else t.gross_lbp end
), 0)
from app.transactions t
where t.customer_id = p_customer
and t.service_code = p_service
and t.status = 'completed'
and t.occurred_at >= date_trunc('day', p_at)
and t.occurred_at < date_trunc('day', p_at) + interval '1 day';
$$;
revoke all on function app.customer_daily_total(uuid, text, app.currency_code, timestamptz) from public;
grant execute on function app.customer_daily_total(uuid, text, app.currency_code, timestamptz) to authenticated;
-- =====================================================================
-- KYC enforcement: check at transaction insert.
-- For OMT/WU services, if the txn amount alone or the customer's
-- running daily total crosses warn → customer mandatory; crosses block
-- → reject unless an owner override row is in place for the day.
-- =====================================================================
create table if not exists app.kyc_overrides (
customer_id uuid not null references app.customers(id),
service_code text not null references app.services(code),
valid_for_day date not null,
approved_by uuid not null references auth.users(id),
reason text not null,
created_at timestamptz not null default now(),
primary key (customer_id, service_code, valid_for_day)
);
create or replace function app._txn_enforce_kyc()
returns trigger
language plpgsql
as $$
declare
th app.kyc_thresholds%rowtype;
amount_usd numeric := new.gross_usd;
amount_lbp numeric := new.gross_lbp;
daily_usd numeric := 0;
daily_lbp numeric := 0;
c app.customers%rowtype;
begin
if new.service_code not in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE') then
return new;
end if;
-- USD branch
select * into th from app.kyc_thresholds
where service_code = new.service_code and currency = 'USD';
if found and amount_usd > 0 then
if new.customer_id is not null then
daily_usd := app.customer_daily_total(new.customer_id, new.service_code, 'USD', new.occurred_at);
end if;
if amount_usd + daily_usd >= th.daily_amount_warn and new.customer_id is null then
raise exception 'KYC: customer record required at or above % USD/day for %',
th.daily_amount_warn, new.service_code;
end if;
if amount_usd + daily_usd >= th.daily_amount_block then
if new.customer_id is null
or not exists (
select 1 from app.kyc_overrides
where customer_id = new.customer_id
and service_code = new.service_code
and valid_for_day = (new.occurred_at at time zone 'UTC')::date
) then
raise exception 'KYC block: % USD/day exceeded for % (owner override required)',
th.daily_amount_block, new.service_code;
end if;
end if;
end if;
-- LBP branch
select * into th from app.kyc_thresholds
where service_code = new.service_code and currency = 'LBP';
if found and amount_lbp > 0 then
if new.customer_id is not null then
daily_lbp := app.customer_daily_total(new.customer_id, new.service_code, 'LBP', new.occurred_at);
end if;
if amount_lbp + daily_lbp >= th.daily_amount_warn and new.customer_id is null then
raise exception 'KYC: customer record required at or above % LBP/day for %',
th.daily_amount_warn, new.service_code;
end if;
if amount_lbp + daily_lbp >= th.daily_amount_block then
if new.customer_id is null
or not exists (
select 1 from app.kyc_overrides
where customer_id = new.customer_id
and service_code = new.service_code
and valid_for_day = (new.occurred_at at time zone 'UTC')::date
) then
raise exception 'KYC block: % LBP/day exceeded for % (owner override required)',
th.daily_amount_block, new.service_code;
end if;
end if;
end if;
-- Hard-blocked / sanctioned customers are never allowed.
if new.customer_id is not null then
select * into c from app.customers where id = new.customer_id;
if c.is_blocked then
raise exception 'customer is blocked: %', coalesce(c.blocked_reason, 'no reason');
end if;
if c.sanctions_hit then
raise exception 'customer is on a sanctions list; transaction refused';
end if;
end if;
return new;
end;
$$;
-- Run KYC checks after the txn_before_insert trigger has populated
-- shop_id/till_id/user_id/reference_no.
drop trigger if exists trg_txn_enforce_kyc on app.transactions;
create trigger trg_txn_enforce_kyc
before insert on app.transactions
for each row execute function app._txn_enforce_kyc();
-- =====================================================================
-- Structuring detection (vector #16):
-- A customer running a high cumulative OMT total via repeated small
-- transfers, or the same beneficiary phone receiving from many cashiers
-- in a short window. Exposed as views for the AML dashboard.
-- =====================================================================
create or replace view app.v_aml_structuring_by_customer as
with d as (
select t.customer_id,
t.service_code,
(t.occurred_at at time zone 'UTC')::date as day,
count(*) as txn_count,
sum(t.gross_usd) as total_usd,
sum(t.gross_lbp) as total_lbp
from app.transactions t
where t.status = 'completed'
and t.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE')
and t.customer_id is not null
group by 1,2,3
)
select d.*,
(select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'USD') as warn_usd,
(select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'LBP') as warn_lbp
from d
where d.txn_count >= 3 -- 3+ same-customer txns
and (
(d.total_usd >= 0.8 * coalesce((select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'USD'), 1e18))
or (d.total_lbp >= 0.8 * coalesce((select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'LBP'), 1e18))
);
create or replace view app.v_aml_same_beneficiary_burst as
select beneficiary_phone,
date_trunc('hour', occurred_at) as hour_bucket,
count(*) as txn_count,
count(distinct user_id) as distinct_cashiers,
sum(gross_usd) as total_usd,
sum(gross_lbp) as total_lbp
from app.transactions
where status = 'completed'
and service_code in ('OMT_SEND','WU_SEND')
and beneficiary_phone is not null
group by 1,2
having count(*) >= 3
and count(distinct user_id) >= 2;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.customers enable row level security;
alter table app.customer_documents enable row level security;
alter table app.kyc_thresholds enable row level security;
alter table app.kyc_overrides enable row level security;
alter table app.customers force row level security;
alter table app.customer_documents force row level security;
alter table app.kyc_thresholds force row level security;
alter table app.kyc_overrides force row level security;
-- Customer rows: visible to anyone authenticated who actively uses
-- the system (cashiers need to find existing customers). Writes are
-- limited; deletion never permitted.
revoke delete on app.customers from authenticated;
revoke update, delete on app.customer_documents from authenticated;
revoke insert, update, delete on app.kyc_thresholds from authenticated;
revoke update, delete on app.kyc_overrides from authenticated;
drop policy if exists customers_select on app.customers;
create policy customers_select on app.customers
for select to authenticated using (true);
drop policy if exists customers_insert on app.customers;
create policy customers_insert on app.customers
for insert to authenticated
with check (auth.uid() is not null);
-- Restrict updates: cashiers may patch contact info; only owners may
-- toggle pep_flag, sanctions_hit, is_blocked. Enforced by trigger.
create or replace function app._customers_update_guard()
returns trigger language plpgsql as $$
begin
if not app.is_owner_anywhere() then
if new.pep_flag is distinct from old.pep_flag
or new.sanctions_hit is distinct from old.sanctions_hit
or new.is_blocked is distinct from old.is_blocked
or coalesce(new.blocked_reason,'') <> coalesce(old.blocked_reason,'') then
raise exception 'only an owner may change pep_flag, sanctions_hit, or is_blocked';
end if;
end if;
return new;
end;
$$;
drop trigger if exists trg_customers_update_guard on app.customers;
create trigger trg_customers_update_guard
before update on app.customers
for each row execute function app._customers_update_guard();
drop policy if exists customers_update on app.customers;
create policy customers_update on app.customers
for update to authenticated
using (auth.uid() is not null)
with check (auth.uid() is not null);
drop policy if exists customer_docs_select on app.customer_documents;
create policy customer_docs_select on app.customer_documents
for select to authenticated using (true);
drop policy if exists customer_docs_insert on app.customer_documents;
create policy customer_docs_insert on app.customer_documents
for insert to authenticated
with check (auth.uid() is not null);
grant select, insert on app.customer_documents to authenticated;
drop policy if exists kyc_thr_select on app.kyc_thresholds;
create policy kyc_thr_select on app.kyc_thresholds
for select to authenticated using (true);
-- thresholds are owner-only; until an owner-edit function lands, only
-- DBA can change them.
drop policy if exists kyc_ovr_select on app.kyc_overrides;
create policy kyc_ovr_select on app.kyc_overrides
for select to authenticated
using (app.is_owner_anywhere() or approved_by = auth.uid());
drop policy if exists kyc_ovr_insert on app.kyc_overrides;
create policy kyc_ovr_insert on app.kyc_overrides
for insert to authenticated
with check (app.is_owner_anywhere() and approved_by = auth.uid());
grant select, insert on app.kyc_overrides to authenticated;
grant select, insert, update on app.customers to authenticated;
grant select on app.kyc_thresholds to authenticated;
-- End migration 0006 ----------------------------------------------------
@@ -0,0 +1,519 @@
-- =====================================================================
-- Migration 0007 — Receipts, signatures, evidence (roadmap Step 8).
--
-- Goals:
-- * Every receipt carries a server-signed token (HMAC-SHA256) so a
-- scanner / owner spot-check can verify it really came from this
-- system and was not printed by a side-printer or hand-edited
-- (vector #10).
-- * Customer notifications (SMS / email) are logged so the owner can
-- confirm that beneficiaries actually got their reference number,
-- exposing pocketed transactions (vector #1).
-- * Evidence (signature pad image, ID photo, voided-paper photo, OMT
-- POS slip scan) is attached append-only to a transaction.
--
-- Threat-model rows addressed: 1, 10, 11, 15.
-- =====================================================================
-- =====================================================================
-- HMAC secret
-- The signing key lives in app.system_secrets and is never returned to
-- clients (the `select` policy denies all non-DBA access). Functions
-- below are SECURITY DEFINER so they can read it.
-- =====================================================================
create table if not exists app.system_secrets (
key text primary key,
value text not null,
rotated_at timestamptz not null default now()
);
-- Generate an initial random key on first install. Owners should rotate
-- it via app.rotate_receipt_key() (added below) on a schedule.
insert into app.system_secrets(key, value)
values ('receipt_hmac_key', encode(gen_random_bytes(32), 'hex'))
on conflict (key) do nothing;
alter table app.system_secrets enable row level security;
alter table app.system_secrets force row level security;
revoke all on app.system_secrets from authenticated;
-- =====================================================================
-- Receipt token: HMAC over (txn_id || reference_no || shop_id || row_hash)
-- Embedded in the printed QR. Anyone holding a receipt + the public
-- verifier function can prove (or disprove) authenticity.
-- =====================================================================
create or replace function app._receipt_hmac_key()
returns bytea
language sql
security definer
set search_path = app, public
stable
as $$
select decode(value, 'hex') from app.system_secrets where key = 'receipt_hmac_key';
$$;
revoke all on function app._receipt_hmac_key() from public;
-- Not granted to anyone; only callable from inside other SECURITY DEFINER
-- functions in this schema.
create or replace function app.receipt_token(p_txn uuid)
returns text
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare
t app.transactions%rowtype;
msg bytea;
sig bytea;
begin
select * into t from app.transactions where id = p_txn;
if t.id is null then raise exception 'txn not found'; end if;
-- Visibility check: caller must be allowed to see the txn.
if not app._can_see_txn(p_txn) then
raise exception 'not authorized';
end if;
msg := convert_to(
t.id::text || '|' || t.shop_id::text || '|' || t.reference_no::text
|| '|' || encode(t.row_hash, 'hex'),
'UTF8');
sig := hmac(msg, app._receipt_hmac_key(), 'sha256');
-- Token format: v1.<txn_id>.<reference_no>.<sig_b64>
return 'v1.' || t.id::text || '.' || t.reference_no::text || '.' ||
translate(encode(sig, 'base64'), E'+/=\n', '-_');
end;
$$;
revoke all on function app.receipt_token(uuid) from public;
grant execute on function app.receipt_token(uuid) to authenticated;
-- Public verifier: takes a token, returns the txn row + ok flag.
-- Anyone authenticated may call (so an owner can scan any receipt) but
-- the row is only returned if the signature checks out AND the caller
-- is allowed to see the txn under RLS.
create or replace function app.verify_receipt(p_token text)
returns table (
ok boolean,
txn_id uuid,
shop_id uuid,
reference_no bigint,
service_code text,
occurred_at timestamptz,
status app.txn_status
)
language plpgsql
security definer
set search_path = app, public
stable
as $$
declare
parts text[];
v_txn uuid;
v_ref bigint;
v_sig_b64 text;
expected text;
t app.transactions%rowtype;
begin
parts := string_to_array(p_token, '.');
if array_length(parts, 1) <> 4 or parts[1] <> 'v1' then
ok := false; return next; return;
end if;
v_txn := parts[2]::uuid;
v_ref := parts[3]::bigint;
v_sig_b64 := parts[4];
select * into t from app.transactions where id = v_txn and reference_no = v_ref;
if t.id is null then
ok := false; return next; return;
end if;
expected := translate(
encode(
hmac(
convert_to(t.id::text || '|' || t.shop_id::text || '|' ||
t.reference_no::text || '|' || encode(t.row_hash, 'hex'), 'UTF8'),
app._receipt_hmac_key(), 'sha256'),
'base64'),
E'+/=\n', '-_');
if expected <> v_sig_b64 then
ok := false; return next; return;
end if;
if not app._can_see_txn(t.id) then
ok := false; return next; return;
end if;
ok := true;
txn_id := t.id;
shop_id := t.shop_id;
reference_no := t.reference_no;
service_code := t.service_code;
occurred_at := t.occurred_at;
status := t.status;
return next;
end;
$$;
revoke all on function app.verify_receipt(text) from public;
grant execute on function app.verify_receipt(text) to authenticated;
-- Key rotation (owner-only).
create or replace function app.rotate_receipt_key()
returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
if not app.is_owner_anywhere() then
raise exception 'only an owner may rotate the receipt key';
end if;
update app.system_secrets
set value = encode(gen_random_bytes(32), 'hex'),
rotated_at = now()
where key = 'receipt_hmac_key';
perform app.log_auth_event('receipt_key_rotated', null, null, '{}'::jsonb);
end;
$$;
revoke all on function app.rotate_receipt_key() from public;
grant execute on function app.rotate_receipt_key() to authenticated;
-- =====================================================================
-- Receipts table: one row per print of a receipt (originals + reprints).
-- Append-only.
-- =====================================================================
do $$ begin
create type app.receipt_kind as enum ('original', 'reprint', 'duplicate');
exception when duplicate_object then null; end $$;
create table if not exists app.receipts (
id uuid primary key default gen_random_uuid(),
txn_id uuid not null references app.transactions(id) on delete restrict,
kind app.receipt_kind not null default 'original',
pdf_url text, -- server-rendered PDF
qr_token text not null, -- embedded HMAC token
printed_at timestamptz not null default now(),
printed_by uuid not null references auth.users(id) default auth.uid(),
device_fingerprint text
);
create index if not exists idx_receipts_txn on app.receipts(txn_id);
create or replace function app._receipts_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'receipts is append-only'; end;
$$;
drop trigger if exists trg_receipts_freeze on app.receipts;
create trigger trg_receipts_freeze before update or delete on app.receipts
for each row execute function app._receipts_no_update_delete();
-- =====================================================================
-- Customer notifications (SMS/email). Logged so an owner can confirm
-- the customer actually heard about the transaction.
-- =====================================================================
do $$ begin
create type app.notification_channel as enum ('sms','email','push');
exception when duplicate_object then null; end $$;
do $$ begin
create type app.notification_status as enum
('queued','sent','delivered','failed');
exception when duplicate_object then null; end $$;
create table if not exists app.customer_notifications (
id uuid primary key default gen_random_uuid(),
txn_id uuid not null references app.transactions(id) on delete restrict,
channel app.notification_channel not null,
recipient text not null, -- phone or email
body_template text not null, -- 'omt_send_v1', 'recharge_v1', ...
status app.notification_status not null default 'queued',
provider_ref text, -- gateway message id
queued_at timestamptz not null default now(),
sent_at timestamptz,
delivered_at timestamptz,
failed_reason text,
created_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_notif_txn on app.customer_notifications(txn_id);
create index if not exists idx_notif_recipient on app.customer_notifications(recipient, queued_at desc);
create index if not exists idx_notif_status on app.customer_notifications(status);
-- Append-only except for status transitions, which only the gateway
-- (running as a dedicated DB role outside `authenticated`) may apply.
create or replace function app._notif_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'notifications cannot be deleted';
end if;
-- Allow status / timestamps / provider_ref / failed_reason updates.
if (new.id <> old.id
or new.txn_id <> old.txn_id
or new.channel <> old.channel
or new.recipient <> old.recipient
or new.body_template <> old.body_template
or new.queued_at <> old.queued_at
or new.created_by <> old.created_by) then
raise exception 'only delivery fields may change on a notification row';
end if;
return new;
end;
$$;
drop trigger if exists trg_notif_guard on app.customer_notifications;
create trigger trg_notif_guard before update or delete on app.customer_notifications
for each row execute function app._notif_guard();
-- =====================================================================
-- Evidence attachments (signatures, photos, ID scans).
-- Append-only. Visible to anyone who can see the parent txn.
-- =====================================================================
do $$ begin
create type app.evidence_kind as enum (
'customer_signature',
'id_photo',
'voided_paper_photo',
'omt_pos_slip',
'cancellation_photo',
'other'
);
exception when duplicate_object then null; end $$;
create table if not exists app.transaction_evidence (
id uuid primary key default gen_random_uuid(),
txn_id uuid not null references app.transactions(id) on delete restrict,
kind app.evidence_kind not null,
file_url text not null,
file_sha256 text, -- hex digest of stored bytes
note text,
uploaded_at timestamptz not null default now(),
uploaded_by uuid not null references auth.users(id) default auth.uid()
);
create index if not exists idx_evidence_txn on app.transaction_evidence(txn_id, uploaded_at);
create or replace function app._evidence_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'transaction_evidence is append-only'; end;
$$;
drop trigger if exists trg_evidence_freeze on app.transaction_evidence;
create trigger trg_evidence_freeze before update or delete on app.transaction_evidence
for each row execute function app._evidence_no_update_delete();
-- =====================================================================
-- High-value evidence policy (vector #15 — fake cancellations,
-- vector #11 — manager-approved void of a printed receipt):
-- a deferred constraint trigger enforces, at COMMIT, that:
-- * any voided txn whose original status was 'completed' has at
-- least one evidence row of kind 'voided_paper_photo'.
-- * any large OMT_SEND / OMT_RECEIVE has a 'customer_signature' or
-- 'id_photo' evidence row.
-- =====================================================================
create or replace function app._txn_require_evidence()
returns trigger language plpgsql as $$
declare
th_warn_usd numeric;
th_warn_lbp numeric;
has_sig boolean;
has_void boolean;
begin
-- Only check on UPDATE-to-voided or on relevant high-value services.
if tg_op = 'UPDATE' and new.status = 'voided' and old.status = 'completed' then
select exists(
select 1 from app.transaction_evidence
where txn_id = new.id and kind = 'voided_paper_photo'
) into has_void;
if not has_void then
raise exception 'void of txn % requires a voided_paper_photo evidence row', new.id;
end if;
end if;
if (tg_op = 'INSERT')
and new.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE') then
select daily_amount_warn into th_warn_usd from app.kyc_thresholds
where service_code = new.service_code and currency = 'USD';
select daily_amount_warn into th_warn_lbp from app.kyc_thresholds
where service_code = new.service_code and currency = 'LBP';
if (new.gross_usd >= coalesce(th_warn_usd, 1e18))
or (new.gross_lbp >= coalesce(th_warn_lbp, 1e18)) then
select exists(
select 1 from app.transaction_evidence
where txn_id = new.id
and kind in ('customer_signature','id_photo','omt_pos_slip')
) into has_sig;
if not has_sig then
raise exception
'high-value % txn % requires customer_signature or id_photo evidence',
new.service_code, new.id;
end if;
end if;
end if;
return null;
end;
$$;
drop trigger if exists trg_txn_require_evidence on app.transactions;
create constraint trigger trg_txn_require_evidence
after insert or update on app.transactions
deferrable initially deferred
for each row execute function app._txn_require_evidence();
-- =====================================================================
-- Convenience: a SECURITY DEFINER `record_receipt_print` so the
-- printing service inside the app issues a fresh QR token and logs the
-- print in one go.
-- =====================================================================
create or replace function app.record_receipt_print(
p_txn_id uuid,
p_kind app.receipt_kind default 'original',
p_device text default null
)
returns table (receipt_id uuid, qr_token text, pdf_url text)
language plpgsql
security definer
set search_path = app, public
as $$
declare
tok text;
rid uuid;
begin
if not app._can_see_txn(p_txn_id) then
raise exception 'not authorized';
end if;
tok := app.receipt_token(p_txn_id);
insert into app.receipts(txn_id, kind, qr_token, device_fingerprint)
values (p_txn_id, p_kind, tok, p_device)
returning id into rid;
receipt_id := rid;
qr_token := tok;
pdf_url := null; -- the PDF rendering service will patch this
-- via record_receipt_pdf below.
return next;
end;
$$;
revoke all on function app.record_receipt_print(uuid, app.receipt_kind, text) from public;
grant execute on function app.record_receipt_print(uuid, app.receipt_kind, text) to authenticated;
-- The PDF renderer fills in pdf_url after upload to storage. The
-- `receipts` table is append-only via trigger, so we expose a tiny
-- definer function that allows just this one column update.
create or replace function app.record_receipt_pdf(
p_receipt_id uuid,
p_pdf_url text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
-- Allow direct UPDATE only via this function.
perform set_config('app.receipts_internal', 'on', true);
update app.receipts set pdf_url = p_pdf_url where id = p_receipt_id and pdf_url is null;
perform set_config('app.receipts_internal', 'off', true);
end;
$$;
revoke all on function app.record_receipt_pdf(uuid, text) from public;
grant execute on function app.record_receipt_pdf(uuid, text) to authenticated;
-- Adjust the receipts-freeze trigger to allow the definer path through.
create or replace function app._receipts_no_update_delete()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'receipts cannot be deleted';
end if;
if current_setting('app.receipts_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on receipts is not allowed';
end if;
if (new.id <> old.id or new.txn_id <> old.txn_id or new.kind <> old.kind
or new.qr_token <> old.qr_token or new.printed_at <> old.printed_at
or new.printed_by <> old.printed_by) then
raise exception 'only pdf_url may change on a receipt row';
end if;
return new;
end;
$$;
-- =====================================================================
-- Customer-facing notification queue helper. The actual SMS gateway
-- (a worker process running as a dedicated role) will pick up rows
-- where status='queued' and update status to sent/delivered/failed.
-- =====================================================================
create or replace function app.queue_customer_notification(
p_txn uuid,
p_channel app.notification_channel,
p_recipient text,
p_template text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare nid uuid;
begin
if not app._can_see_txn(p_txn) then
raise exception 'not authorized';
end if;
insert into app.customer_notifications(txn_id, channel, recipient, body_template)
values (p_txn, p_channel, p_recipient, p_template)
returning id into nid;
return nid;
end;
$$;
revoke all on function app.queue_customer_notification(uuid, app.notification_channel, text, text) from public;
grant execute on function app.queue_customer_notification(uuid, app.notification_channel, text, text) to authenticated;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.receipts enable row level security;
alter table app.customer_notifications enable row level security;
alter table app.transaction_evidence enable row level security;
alter table app.receipts force row level security;
alter table app.customer_notifications force row level security;
alter table app.transaction_evidence force row level security;
revoke update, delete on app.receipts from authenticated;
revoke delete on app.customer_notifications from authenticated;
revoke update, delete on app.transaction_evidence from authenticated;
drop policy if exists receipts_select on app.receipts;
create policy receipts_select on app.receipts
for select to authenticated using (app._can_see_txn(txn_id));
drop policy if exists receipts_insert on app.receipts;
create policy receipts_insert on app.receipts
for insert to authenticated
with check (app._can_see_txn(txn_id));
grant select, insert on app.receipts to authenticated;
drop policy if exists notif_select on app.customer_notifications;
create policy notif_select on app.customer_notifications
for select to authenticated using (app._can_see_txn(txn_id));
drop policy if exists notif_insert on app.customer_notifications;
create policy notif_insert on app.customer_notifications
for insert to authenticated with check (app._can_see_txn(txn_id));
grant select, insert on app.customer_notifications to authenticated;
-- The gateway worker role gets UPDATE separately; not here.
drop policy if exists evidence_select on app.transaction_evidence;
create policy evidence_select on app.transaction_evidence
for select to authenticated using (app._can_see_txn(txn_id));
-- Evidence insert allowed for: shift owner during open shift OR any
-- manager/owner of the shop (so a manager can attach voided-paper
-- photos when approving a void after the cashier has closed shift).
drop policy if exists evidence_insert on app.transaction_evidence;
create policy evidence_insert on app.transaction_evidence
for insert to authenticated
with check (
exists (
select 1 from app.transactions t
join app.shifts s on s.id = t.shift_id
where t.id = transaction_evidence.txn_id
and (
(t.user_id = auth.uid() and s.status = 'open')
or app.has_any_role_in_shop(t.shop_id,
array['manager','owner']::app.business_role[])
)
)
);
grant select, insert on app.transaction_evidence to authenticated;
-- End migration 0007 ----------------------------------------------------
@@ -0,0 +1,430 @@
-- =====================================================================
-- Migration 0008 — Refunds, price overrides, void hardening
-- (roadmap Step 9).
--
-- Voids already exist (0003). This migration adds:
-- * Refunds as their own ledger row, never as a reverse-edit of the
-- original (vector #11).
-- * Price overrides on goods sales: only manager + PIN, capped at
-- a per-shop `max_discount_pct`, fully audited (vector #12).
-- * Void/refund/override summary views per cashier and per
-- (cashier, manager) pair to expose collusion (vector #18).
--
-- Threat-model rows addressed: 11, 12, 18.
-- =====================================================================
-- =====================================================================
-- Refunds
-- =====================================================================
-- A refund is recorded as a transaction with service_code 'REFUND'
-- linked back to the original txn via app.refunds. Money signs are kept
-- positive on the row; cash flows are negative for the shop and are
-- reflected via paired cash_movements / float_movements just like sales.
insert into app.services(code, name, category) values
('REFUND', 'Customer Refund', 'refund')
on conflict (code) do nothing;
create table if not exists app.refunds (
id uuid primary key default gen_random_uuid(),
refund_txn_id uuid not null references app.transactions(id) on delete restrict,
original_txn_id uuid not null references app.transactions(id) on delete restrict,
reason text not null,
manager_approved_by uuid not null references auth.users(id),
amount_usd numeric(14,2) not null default 0 check (amount_usd >= 0),
amount_lbp numeric(18,0) not null default 0 check (amount_lbp >= 0),
created_at timestamptz not null default now(),
constraint refunds_no_self check (refund_txn_id <> original_txn_id),
constraint refunds_unique_refund_txn unique (refund_txn_id)
);
create index if not exists idx_refunds_original on app.refunds(original_txn_id);
create or replace function app._refunds_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'refunds is append-only'; end;
$$;
drop trigger if exists trg_refunds_freeze on app.refunds;
create trigger trg_refunds_freeze before update or delete on app.refunds
for each row execute function app._refunds_no_update_delete();
-- The single legal way to issue a refund. Enforces manager role + PIN,
-- amount ≤ original (minus any prior refunds), original is completed,
-- and creates the refund txn + linkage atomically.
create or replace function app.issue_refund(
p_original_txn uuid,
p_amount_usd numeric,
p_amount_lbp numeric,
p_reason text,
p_manager_pin text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
o app.transactions%rowtype;
s app.shifts%rowtype;
prior_usd numeric := 0;
prior_lbp numeric := 0;
refund_id uuid;
refund_txn uuid;
begin
if p_amount_usd is null or p_amount_lbp is null
or p_amount_usd < 0 or p_amount_lbp < 0
or (p_amount_usd = 0 and p_amount_lbp = 0) then
raise exception 'refund amount must be >= 0 and at least one currency > 0';
end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'reason >= 5 chars required';
end if;
select * into o from app.transactions where id = p_original_txn;
if o.id is null then raise exception 'original txn not found'; end if;
if o.status <> 'completed' then
raise exception 'cannot refund a % transaction', o.status;
end if;
-- Caller must be a manager in the same shop and prove it via PIN.
if not app.has_role_in_shop(o.shop_id, 'manager') then
raise exception 'manager role required to issue a refund';
end if;
if not app.verify_my_pin(p_manager_pin) then
raise exception 'invalid manager PIN';
end if;
-- Refund must be issued on the manager's currently open shift in
-- this shop (so the cash leaves the right till).
select * into s from app.shifts
where shop_id = o.shop_id and status = 'open' and user_id = auth.uid()
limit 1;
if s.id is null then
raise exception 'manager has no open shift in shop % to issue the refund from', o.shop_id;
end if;
-- Prior refunds against this original.
select coalesce(sum(amount_usd),0), coalesce(sum(amount_lbp),0)
into prior_usd, prior_lbp
from app.refunds where original_txn_id = p_original_txn;
if (prior_usd + p_amount_usd) > o.gross_usd then
raise exception 'refund USD exceeds remaining refundable amount (% > %)',
prior_usd + p_amount_usd, o.gross_usd;
end if;
if (prior_lbp + p_amount_lbp) > o.gross_lbp then
raise exception 'refund LBP exceeds remaining refundable amount (% > %)',
prior_lbp + p_amount_lbp, o.gross_lbp;
end if;
-- Create the refund transaction. The standard txn triggers (server
-- stamping, hash chain, sequence) all apply.
insert into app.transactions(
shift_id, shop_id, till_id, user_id, service_code,
gross_usd, gross_lbp, fee_usd, fee_lbp,
payment_method, notes
) values (
s.id, o.shop_id, s.till_id, auth.uid(), 'REFUND',
p_amount_usd, p_amount_lbp, 0, 0,
o.payment_method, 'refund of ' || o.id::text || '' || p_reason
) returning id into refund_txn;
insert into app.refunds(refund_txn_id, original_txn_id, reason,
manager_approved_by, amount_usd, amount_lbp)
values (refund_txn, p_original_txn, p_reason, auth.uid(),
p_amount_usd, p_amount_lbp)
returning id into refund_id;
-- Cash leaves the till (negative cash_movements). Currency split.
if p_amount_usd > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (s.id, 'payout_out', 'USD', -p_amount_usd, refund_txn, 'refund');
end if;
if p_amount_lbp > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (s.id, 'payout_out', 'LBP', -p_amount_lbp, refund_txn, 'refund');
end if;
perform app.log_auth_event('refund_issued', o.shop_id, null,
jsonb_build_object('original', p_original_txn, 'refund_txn', refund_txn,
'amount_usd', p_amount_usd, 'amount_lbp', p_amount_lbp));
return refund_txn;
end;
$$;
revoke all on function app.issue_refund(uuid, numeric, numeric, text, text) from public;
grant execute on function app.issue_refund(uuid, numeric, numeric, text, text) to authenticated;
-- The REFUND service does not need a child detail row; teach the
-- detail-required check to skip it.
create or replace function app.txn_require_detail()
returns trigger
language plpgsql
as $$
declare ok boolean;
begin
if new.status <> 'completed' then return null; end if;
case new.service_code
when 'OMT_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
when 'OMT_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
when 'OMT_BILL' then select exists(select 1 from app.bill_payment_details where txn_id = new.id) into ok;
when 'WU_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
when 'WU_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
when 'ALFA_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'TOUCH_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'OGERO_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'INTERNET_RECHARGE'then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
when 'SIM_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'PHONE_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'ACCESSORY_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
when 'REPAIR' then select exists(select 1 from app.repair_details where txn_id = new.id) into ok;
when 'REFUND' then select exists(select 1 from app.refunds where refund_txn_id = new.id) into ok;
else ok := true;
end case;
if not ok then
raise exception 'transaction % (service %) is missing its detail/refund row',
new.id, new.service_code;
end if;
return null;
end;
$$;
-- =====================================================================
-- Price overrides on goods sales
-- =====================================================================
-- Per-shop policy: maximum discount % a manager can authorize without
-- escalating to owner.
create table if not exists app.shop_pricing_policy (
shop_id uuid primary key references app.shops(id) on delete cascade,
max_discount_pct numeric(5,2) not null default 10.00 check (max_discount_pct between 0 and 50),
updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id)
);
-- Append-only audit table for every override.
create table if not exists app.price_overrides (
id uuid primary key default gen_random_uuid(),
txn_id uuid not null references app.transactions(id) on delete restrict,
sku text not null references app.items(sku),
list_price_usd numeric(14,2) not null check (list_price_usd > 0),
sold_price_usd numeric(14,2) not null check (sold_price_usd >= 0),
discount_pct numeric(6,2) not null,
reason text not null,
approved_by uuid not null references auth.users(id),
approver_role app.business_role not null,
created_at timestamptz not null default now(),
constraint price_override_unique_per_txn_sku unique (txn_id, sku)
);
create index if not exists idx_price_ovr_txn on app.price_overrides(txn_id);
create or replace function app._price_overrides_no_update_delete()
returns trigger language plpgsql as $$
begin raise exception 'price_overrides is append-only'; end;
$$;
drop trigger if exists trg_price_ovr_freeze on app.price_overrides;
create trigger trg_price_ovr_freeze before update or delete on app.price_overrides
for each row execute function app._price_overrides_no_update_delete();
-- Definer function: the only legal way to authorize a discount.
-- Returns the approved sold_price; caller passes it into the goods
-- sale flow.
create or replace function app.authorize_price_override(
p_txn_id uuid,
p_sku text,
p_sold_price numeric,
p_reason text,
p_manager_pin text
) returns numeric
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
i app.items%rowtype;
pol app.shop_pricing_policy%rowtype;
pct numeric;
role_used app.business_role;
begin
if p_sold_price is null or p_sold_price < 0 then
raise exception 'sold price must be >= 0';
end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'reason >= 5 chars required';
end if;
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'txn not found'; end if;
if t.status <> 'completed' then
raise exception 'cannot override price on a % transaction', t.status;
end if;
select * into i from app.items where sku = p_sku;
if i.sku is null then raise exception 'sku not found'; end if;
if p_sold_price > i.price_usd then
raise exception 'sold price > list price; not an override';
end if;
pct := round(((i.price_usd - p_sold_price) / nullif(i.price_usd,0)) * 100.0, 2);
-- Caller must be manager or owner in this shop AND give a valid PIN.
if app.has_role_in_shop(t.shop_id, 'owner') then
role_used := 'owner';
elsif app.has_role_in_shop(t.shop_id, 'manager') then
role_used := 'manager';
else
raise exception 'manager or owner role required to override price';
end if;
if not app.verify_my_pin(p_manager_pin) then
raise exception 'invalid PIN';
end if;
-- Check shop policy ceiling for managers. Owners can go beyond.
select * into pol from app.shop_pricing_policy where shop_id = t.shop_id;
if not found then
insert into app.shop_pricing_policy(shop_id) values (t.shop_id)
on conflict (shop_id) do nothing;
select * into pol from app.shop_pricing_policy where shop_id = t.shop_id;
end if;
if role_used = 'manager' and pct > pol.max_discount_pct then
raise exception 'discount % %% exceeds shop ceiling % %% (owner approval needed)',
pct, pol.max_discount_pct;
end if;
insert into app.price_overrides(
txn_id, sku, list_price_usd, sold_price_usd, discount_pct,
reason, approved_by, approver_role
) values (
p_txn_id, p_sku, i.price_usd, p_sold_price, pct,
p_reason, auth.uid(), role_used
);
perform app.log_auth_event('price_override', t.shop_id, null,
jsonb_build_object('txn', p_txn_id, 'sku', p_sku, 'pct', pct,
'role', role_used));
return p_sold_price;
end;
$$;
revoke all on function app.authorize_price_override(uuid, text, numeric, text, text) from public;
grant execute on function app.authorize_price_override(uuid, text, numeric, text, text) to authenticated;
-- A goods_sale_details row priced below list price MUST have a matching
-- price_overrides row (deferred so the override can be inserted in the
-- same transaction).
create or replace function app._goods_sale_require_override_if_discounted()
returns trigger language plpgsql as $$
declare i app.items%rowtype;
has_ovr boolean;
begin
select * into i from app.items where sku = new.sku;
if i.sku is null then return null; end if; -- FK will catch it
if new.unit_price_usd < i.price_usd then
select exists(
select 1 from app.price_overrides
where txn_id = new.txn_id and sku = new.sku
and sold_price_usd = new.unit_price_usd
) into has_ovr;
if not has_ovr then
raise exception
'goods sale of % below list price (% < %) requires an authorized price override',
new.sku, new.unit_price_usd, i.price_usd;
end if;
end if;
return null;
end;
$$;
drop trigger if exists trg_goods_sale_require_override on app.goods_sale_details;
create constraint trigger trg_goods_sale_require_override
after insert on app.goods_sale_details
deferrable initially deferred
for each row execute function app._goods_sale_require_override_if_discounted();
-- =====================================================================
-- Reporting views — collusion / abuse signals (vector #18)
-- =====================================================================
-- Daily voids per cashier
create or replace view app.v_voids_by_cashier_day as
select t.shop_id,
(t.occurred_at at time zone 'UTC')::date as day,
t.user_id as cashier_id,
count(*) as void_count,
sum(t.gross_usd) as voided_usd,
sum(t.gross_lbp) as voided_lbp
from app.transactions t
where t.status = 'voided'
group by 1,2,3;
-- Daily refunds per cashier (the cashier of the original txn)
create or replace view app.v_refunds_by_original_cashier_day as
select o.shop_id,
(r.created_at at time zone 'UTC')::date as day,
o.user_id as original_cashier_id,
r.manager_approved_by as approving_manager_id,
count(*) as refund_count,
sum(r.amount_usd) as refunded_usd,
sum(r.amount_lbp) as refunded_lbp
from app.refunds r
join app.transactions o on o.id = r.original_txn_id
group by 1,2,3,4;
-- Cashiermanager pairs with high void+refund volume (collusion signal)
create or replace view app.v_void_refund_pairs as
select t.shop_id,
t.user_id as cashier_id,
t.void_approved_by as manager_id,
date_trunc('week', t.voided_at) as week_bucket,
count(*) as void_count,
sum(t.gross_usd) as voided_usd
from app.transactions t
where t.status = 'voided' and t.void_approved_by is not null
group by 1,2,3,4
having count(*) >= 5;
-- Price-override volume by approver
create or replace view app.v_overrides_by_approver_day as
select t.shop_id,
(po.created_at at time zone 'UTC')::date as day,
po.approved_by,
po.approver_role,
count(*) as override_count,
sum(po.list_price_usd - po.sold_price_usd) as discount_total_usd,
avg(po.discount_pct) as avg_discount_pct
from app.price_overrides po
join app.transactions t on t.id = po.txn_id
group by 1,2,3,4;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.refunds enable row level security;
alter table app.shop_pricing_policy enable row level security;
alter table app.price_overrides enable row level security;
alter table app.refunds force row level security;
alter table app.shop_pricing_policy force row level security;
alter table app.price_overrides force row level security;
revoke insert, update, delete on app.refunds from authenticated;
revoke insert, update, delete on app.price_overrides from authenticated;
revoke insert, update, delete on app.shop_pricing_policy from authenticated;
drop policy if exists refunds_select on app.refunds;
create policy refunds_select on app.refunds
for select to authenticated
using (app._can_see_txn(refund_txn_id));
grant select on app.refunds to authenticated;
drop policy if exists pricing_policy_select on app.shop_pricing_policy;
create policy pricing_policy_select on app.shop_pricing_policy
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','auditor']::app.business_role[])
);
grant select on app.shop_pricing_policy to authenticated;
drop policy if exists price_ovr_select on app.price_overrides;
create policy price_ovr_select on app.price_overrides
for select to authenticated
using (app._can_see_txn(txn_id));
grant select on app.price_overrides to authenticated;
-- End migration 0008 ----------------------------------------------------
@@ -0,0 +1,573 @@
-- =====================================================================
-- Migration 0009 — External reconciliation (roadmap Step 10).
--
-- The strongest fraud control is an external source of truth. Every
-- provider (OMT, Alfa, touch, Ogero, the bank, whish, the card terminal)
-- publishes a settlement statement; we import it line by line and match
-- each line to a local transaction by `external_ref`. Mismatches go to
-- `reconciliation_exceptions` and block month-close.
--
-- Threat-model rows addressed: 1, 5, 8, 21, 24.
-- =====================================================================
-- =====================================================================
-- Settlement headers + lines
-- =====================================================================
do $$ begin
create type app.settlement_provider as enum (
'OMT', 'ALFA', 'TOUCH', 'OGERO', 'WHISH', 'CARD_TERMINAL', 'BANK', 'WU'
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.settlement_status as enum (
'imported', -- file parsed; matching not started
'matching', -- run is in progress
'matched', -- all lines matched, ready for sign-off
'has_exceptions', -- at least one line still unresolved
'closed' -- owner-signed off, immutable
);
exception when duplicate_object then null; end $$;
do $$ begin
create type app.settlement_line_status as enum (
'unmatched', -- no candidate found yet
'matched', -- exactly one candidate, amounts agree
'amount_mismatch', -- candidate found but money differs
'duplicate', -- the same external_ref already used elsewhere
'missing_local', -- provider has it; we don't
'extra_local' -- we have it; provider doesn't
);
exception when duplicate_object then null; end $$;
create table if not exists app.settlements (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
provider app.settlement_provider not null,
period_start date not null,
period_end date not null,
file_url text,
file_sha256 text,
total_amount numeric(20,2), -- as reported by provider
total_currency app.currency_code,
status app.settlement_status not null default 'imported',
imported_at timestamptz not null default now(),
imported_by uuid not null references auth.users(id) default auth.uid(),
closed_at timestamptz,
closed_by uuid references auth.users(id),
notes text,
constraint settlements_period_ok check (period_end >= period_start)
);
create index if not exists idx_settlements_shop_period
on app.settlements(shop_id, provider, period_start);
create table if not exists app.settlement_lines (
id uuid primary key default gen_random_uuid(),
settlement_id uuid not null references app.settlements(id) on delete cascade,
-- Raw fields as parsed from the provider file:
external_ref text not null, -- provider txn id / receipt no
occurred_at timestamptz,
amount numeric(20,2) not null,
currency app.currency_code not null,
fee numeric(20,2),
commission numeric(20,2),
raw jsonb, -- the original parsed row
-- Match output:
matched_txn_id uuid references app.transactions(id),
status app.settlement_line_status not null default 'unmatched',
matched_at timestamptz,
-- A natural key per provider statement keeps imports idempotent.
unique (settlement_id, external_ref)
);
create index if not exists idx_settle_line_status on app.settlement_lines(settlement_id, status);
create index if not exists idx_settle_line_ref on app.settlement_lines(external_ref);
-- Exceptions queue. Every non-matched line generates a row here so the
-- owner has a single place to clear before closing the period.
create table if not exists app.reconciliation_exceptions (
id uuid primary key default gen_random_uuid(),
settlement_id uuid not null references app.settlements(id) on delete cascade,
line_id uuid references app.settlement_lines(id) on delete cascade,
txn_id uuid references app.transactions(id),
type app.settlement_line_status not null,
detail text,
resolved_at timestamptz,
resolved_by uuid references auth.users(id),
resolution_note text,
created_at timestamptz not null default now()
);
create index if not exists idx_recon_exc_open
on app.reconciliation_exceptions(settlement_id) where resolved_at is null;
-- Late FK from float_movements (declared in 0005).
alter table app.float_movements
drop constraint if exists float_mov_settlement_fk;
alter table app.float_movements
add constraint float_mov_settlement_fk
foreign key (ref_settlement_id) references app.settlements(id) on delete restrict;
-- =====================================================================
-- Append-only behaviour where it matters
-- =====================================================================
-- Settlements: status moves are owner-driven via functions below.
create or replace function app._settlements_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'settlements cannot be deleted';
end if;
if old.status = 'closed' then
raise exception 'settlement % is closed and immutable', old.id;
end if;
if current_setting('app.settle_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on settlements is not allowed; use app.* functions';
end if;
return new;
end;
$$;
drop trigger if exists trg_settlements_guard on app.settlements;
create trigger trg_settlements_guard before update or delete on app.settlements
for each row execute function app._settlements_guard();
-- Lines: insert at import time, matched in place by definer functions.
create or replace function app._settle_lines_guard()
returns trigger language plpgsql as $$
declare s app.settlements%rowtype;
begin
if tg_op = 'DELETE' then
raise exception 'settlement_lines cannot be deleted';
end if;
select * into s from app.settlements where id = coalesce(new.settlement_id, old.settlement_id);
if s.status = 'closed' then
raise exception 'cannot modify lines of a closed settlement';
end if;
if current_setting('app.settle_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on settlement_lines is not allowed';
end if;
return new;
end;
$$;
drop trigger if exists trg_settle_lines_guard on app.settlement_lines;
create trigger trg_settle_lines_guard before update or delete on app.settlement_lines
for each row execute function app._settle_lines_guard();
-- Exceptions: insert by matcher; resolution via definer.
create or replace function app._recon_exc_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'reconciliation_exceptions cannot be deleted';
end if;
if current_setting('app.settle_internal', true) is distinct from 'on' then
raise exception 'direct UPDATE on reconciliation_exceptions is not allowed';
end if;
return new;
end;
$$;
drop trigger if exists trg_recon_exc_guard on app.reconciliation_exceptions;
create trigger trg_recon_exc_guard before update or delete on app.reconciliation_exceptions
for each row execute function app._recon_exc_guard();
-- =====================================================================
-- Provider → service code map. Used by the matcher to know which local
-- service rows are eligible candidates for a given settlement file.
-- =====================================================================
create or replace function app._provider_service_codes(p app.settlement_provider)
returns text[]
language sql
immutable
as $$
select case p
when 'OMT' then array['OMT_SEND','OMT_RECEIVE','OMT_BILL']
when 'ALFA' then array['ALFA_RECHARGE']
when 'TOUCH' then array['TOUCH_RECHARGE']
when 'OGERO' then array['OGERO_RECHARGE','INTERNET_RECHARGE']
when 'WU' then array['WU_SEND','WU_RECEIVE']
-- BANK / WHISH / CARD_TERMINAL match by payment_method instead.
else null
end::text[];
$$;
-- =====================================================================
-- Import + match
-- =====================================================================
-- Insert one parsed row from the provider file. Idempotent by
-- (settlement_id, external_ref).
create or replace function app.add_settlement_line(
p_settlement uuid,
p_external_ref text,
p_occurred_at timestamptz,
p_amount numeric,
p_currency app.currency_code,
p_fee numeric,
p_commission numeric,
p_raw jsonb
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare s app.settlements%rowtype; lid uuid;
begin
select * into s from app.settlements where id = p_settlement;
if s.id is null then raise exception 'settlement not found'; end if;
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
raise exception 'owner or manager required';
end if;
if s.status = 'closed' then
raise exception 'settlement is closed';
end if;
insert into app.settlement_lines(settlement_id, external_ref, occurred_at,
amount, currency, fee, commission, raw)
values (p_settlement, p_external_ref, p_occurred_at,
p_amount, p_currency, p_fee, p_commission, p_raw)
on conflict (settlement_id, external_ref) do nothing
returning id into lid;
return lid;
end;
$$;
revoke all on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) from public;
grant execute on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) to authenticated;
-- Run the matcher across all unmatched lines of a settlement.
create or replace function app.run_match(p_settlement uuid)
returns table (matched int, exceptions int)
language plpgsql
security definer
set search_path = app, public
as $$
declare
s app.settlements%rowtype;
svc_codes text[];
ln app.settlement_lines%rowtype;
cand uuid;
cand_count int;
cand_amount_usd numeric;
cand_amount_lbp numeric;
cand_amount numeric;
ok_amount boolean;
m_count int := 0;
e_count int := 0;
begin
select * into s from app.settlements where id = p_settlement;
if s.id is null then raise exception 'settlement not found'; end if;
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
raise exception 'owner or manager required';
end if;
if s.status = 'closed' then raise exception 'settlement is closed'; end if;
svc_codes := app._provider_service_codes(s.provider);
perform set_config('app.settle_internal', 'on', true);
update app.settlements set status = 'matching' where id = p_settlement;
for ln in
select * from app.settlement_lines
where settlement_id = p_settlement and status = 'unmatched'
loop
-- Find candidate(s) by external_ref + provider service codes (when
-- known) within the same shop, in completed state.
select count(*),
coalesce(min(t.id), null)
into cand_count, cand
from app.transactions t
where t.shop_id = s.shop_id
and t.status = 'completed'
and t.external_ref = ln.external_ref
and (svc_codes is null or t.service_code = any(svc_codes));
if cand_count = 0 then
update app.settlement_lines
set status = 'missing_local'
where id = ln.id;
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
values (p_settlement, ln.id, 'missing_local',
format('provider lists external_ref % but no local txn found', ln.external_ref));
e_count := e_count + 1;
elsif cand_count > 1 then
update app.settlement_lines
set status = 'duplicate'
where id = ln.id;
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
values (p_settlement, ln.id, 'duplicate',
format('% local txns share external_ref %', cand_count, ln.external_ref));
e_count := e_count + 1;
else
-- Compare amounts within the matching currency. Allow 0.01 USD /
-- 100 LBP rounding tolerance.
select t.gross_usd, t.gross_lbp into cand_amount_usd, cand_amount_lbp
from app.transactions t where t.id = cand;
cand_amount := case ln.currency
when 'USD' then cand_amount_usd
when 'LBP' then cand_amount_lbp
end;
ok_amount := abs(coalesce(cand_amount,0) - coalesce(ln.amount,0))
<= case ln.currency when 'USD' then 0.01 else 100 end;
if ok_amount then
update app.settlement_lines
set status = 'matched',
matched_txn_id = cand,
matched_at = now()
where id = ln.id;
m_count := m_count + 1;
else
update app.settlement_lines
set status = 'amount_mismatch',
matched_txn_id = cand,
matched_at = now()
where id = ln.id;
insert into app.reconciliation_exceptions(settlement_id, line_id, txn_id, type, detail)
values (p_settlement, ln.id, cand, 'amount_mismatch',
format('local % %s vs provider % %s for ref %',
cand_amount, ln.currency, ln.amount, ln.currency, ln.external_ref));
e_count := e_count + 1;
end if;
end if;
end loop;
-- Now check for `extra_local`: completed local transactions in the
-- period whose external_ref is not present on the provider statement.
if svc_codes is not null then
insert into app.reconciliation_exceptions(settlement_id, txn_id, type, detail)
select p_settlement, t.id, 'extra_local',
format('local txn % has external_ref % but provider did not list it',
t.id, t.external_ref)
from app.transactions t
where t.shop_id = s.shop_id
and t.status = 'completed'
and t.service_code = any(svc_codes)
and t.external_ref is not null
and (t.occurred_at at time zone 'UTC')::date between s.period_start and s.period_end
and not exists (
select 1 from app.settlement_lines sl
where sl.settlement_id = p_settlement
and sl.external_ref = t.external_ref
)
and not exists (
select 1 from app.reconciliation_exceptions r
where r.settlement_id = p_settlement
and r.txn_id = t.id
and r.type = 'extra_local'
);
get diagnostics e_count = row_count; -- approximate increment
end if;
-- Final status
if exists (
select 1 from app.reconciliation_exceptions
where settlement_id = p_settlement and resolved_at is null
) then
update app.settlements set status = 'has_exceptions' where id = p_settlement;
else
update app.settlements set status = 'matched' where id = p_settlement;
end if;
perform set_config('app.settle_internal', 'off', true);
perform app.log_auth_event('settlement_matched', s.shop_id, null,
jsonb_build_object('settlement_id', p_settlement,
'matched', m_count, 'exceptions', e_count));
matched := m_count; exceptions := e_count; return next;
end;
$$;
revoke all on function app.run_match(uuid) from public;
grant execute on function app.run_match(uuid) to authenticated;
-- Resolve a single exception. Owner-only with mandatory note.
create or replace function app.resolve_exception(
p_exception uuid,
p_note text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
e app.reconciliation_exceptions%rowtype;
s app.settlements%rowtype;
begin
if p_note is null or length(btrim(p_note)) < 5 then
raise exception 'resolution note >= 5 chars required';
end if;
select * into e from app.reconciliation_exceptions where id = p_exception;
if e.id is null then raise exception 'exception not found'; end if;
select * into s from app.settlements where id = e.settlement_id;
if not app.has_role_in_shop(s.shop_id, 'owner') then
raise exception 'owner role required';
end if;
if e.resolved_at is not null then
raise exception 'exception already resolved';
end if;
perform set_config('app.settle_internal', 'on', true);
update app.reconciliation_exceptions
set resolved_at = now(),
resolved_by = auth.uid(),
resolution_note = p_note
where id = p_exception;
-- If no open exceptions remain on this settlement, flip back to matched.
if not exists (
select 1 from app.reconciliation_exceptions
where settlement_id = s.id and resolved_at is null
) then
update app.settlements set status = 'matched' where id = s.id;
end if;
perform set_config('app.settle_internal', 'off', true);
perform app.log_auth_event('settlement_exception_resolved', s.shop_id, null,
jsonb_build_object('exception_id', p_exception));
end;
$$;
revoke all on function app.resolve_exception(uuid, text) from public;
grant execute on function app.resolve_exception(uuid, text) to authenticated;
-- Close the settlement once all exceptions are resolved.
create or replace function app.close_settlement(p_settlement uuid)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare s app.settlements%rowtype;
begin
select * into s from app.settlements where id = p_settlement;
if s.id is null then raise exception 'settlement not found'; end if;
if not app.has_role_in_shop(s.shop_id, 'owner') then
raise exception 'owner role required';
end if;
if s.status not in ('matched') then
raise exception 'settlement must be in MATCHED state to close (was %)', s.status;
end if;
if exists (
select 1 from app.reconciliation_exceptions
where settlement_id = p_settlement and resolved_at is null
) then
raise exception 'cannot close: open exceptions remain';
end if;
perform set_config('app.settle_internal', 'on', true);
update app.settlements
set status = 'closed', closed_at = now(), closed_by = auth.uid()
where id = p_settlement;
perform set_config('app.settle_internal', 'off', true);
perform app.log_auth_event('settlement_closed', s.shop_id, null,
jsonb_build_object('settlement_id', p_settlement));
end;
$$;
revoke all on function app.close_settlement(uuid) from public;
grant execute on function app.close_settlement(uuid) to authenticated;
-- =====================================================================
-- Reporting views
-- =====================================================================
create or replace view app.v_unmatched_external as
select s.shop_id, s.provider, s.period_start, s.period_end,
sl.external_ref, sl.amount, sl.currency, sl.status
from app.settlement_lines sl
join app.settlements s on s.id = sl.settlement_id
where sl.status <> 'matched';
create or replace view app.v_open_exceptions as
select s.shop_id, s.provider, s.period_start, s.period_end,
e.id as exception_id, e.type, e.detail, e.created_at
from app.reconciliation_exceptions e
join app.settlements s on s.id = e.settlement_id
where e.resolved_at is null;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.settlements enable row level security;
alter table app.settlement_lines enable row level security;
alter table app.reconciliation_exceptions enable row level security;
alter table app.settlements force row level security;
alter table app.settlement_lines force row level security;
alter table app.reconciliation_exceptions force row level security;
revoke insert, update, delete on app.settlements from authenticated;
revoke insert, update, delete on app.settlement_lines from authenticated;
revoke insert, update, delete on app.reconciliation_exceptions from authenticated;
drop policy if exists settle_select on app.settlements;
create policy settle_select on app.settlements
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','auditor']::app.business_role[])
);
grant select on app.settlements to authenticated;
drop policy if exists settle_lines_select on app.settlement_lines;
create policy settle_lines_select on app.settlement_lines
for select to authenticated
using (
exists (
select 1 from app.settlements s
where s.id = settlement_lines.settlement_id
and app.has_any_role_in_shop(s.shop_id,
array['owner','manager','auditor']::app.business_role[])
)
);
grant select on app.settlement_lines to authenticated;
drop policy if exists recon_exc_select on app.reconciliation_exceptions;
create policy recon_exc_select on app.reconciliation_exceptions
for select to authenticated
using (
exists (
select 1 from app.settlements s
where s.id = reconciliation_exceptions.settlement_id
and app.has_any_role_in_shop(s.shop_id,
array['owner','manager','auditor']::app.business_role[])
)
);
grant select on app.reconciliation_exceptions to authenticated;
-- A small helper to create a settlement (owner/manager only).
create or replace function app.create_settlement(
p_shop uuid,
p_provider app.settlement_provider,
p_period_start date,
p_period_end date,
p_file_url text,
p_file_sha256 text,
p_total_amount numeric,
p_total_currency app.currency_code
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare sid uuid;
begin
if not app.has_any_role_in_shop(p_shop, array['owner','manager']::app.business_role[]) then
raise exception 'owner or manager required';
end if;
if p_period_end < p_period_start then
raise exception 'period_end before period_start';
end if;
insert into app.settlements(shop_id, provider, period_start, period_end,
file_url, file_sha256, total_amount, total_currency)
values (p_shop, p_provider, p_period_start, p_period_end,
p_file_url, p_file_sha256, p_total_amount, p_total_currency)
returning id into sid;
perform app.log_auth_event('settlement_imported', p_shop, null,
jsonb_build_object('settlement_id', sid, 'provider', p_provider));
return sid;
end;
$$;
revoke all on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) from public;
grant execute on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) to authenticated;
-- End migration 0009 ----------------------------------------------------
@@ -0,0 +1,511 @@
-- =====================================================================
-- Migration 0010 — Reporting and alerting (roadmap Step 11).
--
-- Owner-facing read model: Z-reports, daily P&L per service, employee
-- scorecards, and a persistent alerts table fed by detector functions.
--
-- Threat-model rows addressed: 2, 3, 4, 6, 9, 10, 11, 12, 13, 14, 17,
-- 18, 20, 22, 23, 24, 25.
-- =====================================================================
-- =====================================================================
-- Z-report: one row per closed shift, what the system says vs what the
-- cashier declared vs what was found in the drawer.
-- =====================================================================
create or replace view app.v_z_report as
with cm as (
select sh.id as shift_id,
coalesce(sum(amount) filter (where currency='USD'),0) as net_usd,
coalesce(sum(amount) filter (where currency='LBP'),0) as net_lbp
from app.shifts sh
left join app.cash_movements m on m.shift_id = sh.id
group by sh.id
),
txn as (
select sh.id as shift_id,
count(*) filter (where t.status='completed') as txn_count,
count(*) filter (where t.status='voided') as void_count,
coalesce(sum(t.gross_usd) filter (where t.status='completed'),0) as gross_usd,
coalesce(sum(t.gross_lbp) filter (where t.status='completed'),0) as gross_lbp,
coalesce(sum(t.fee_usd) filter (where t.status='completed'),0) as fee_usd,
coalesce(sum(t.fee_lbp) filter (where t.status='completed'),0) as fee_lbp,
coalesce(sum(t.commission_usd) filter (where t.status='completed'),0) as comm_usd,
coalesce(sum(t.commission_lbp) filter (where t.status='completed'),0) as comm_lbp
from app.shifts sh
left join app.transactions t
on t.shift_id = sh.id
group by sh.id
)
select
sh.id as shift_id,
sh.shop_id,
sh.till_id,
sh.user_id as cashier_id,
sh.opened_at,
sh.closed_at,
sh.status,
sh.opening_usd,
sh.opening_lbp,
cm.net_usd as expected_close_usd, -- = sum(cash_movements USD)
cm.net_lbp as expected_close_lbp,
sh.declared_close_usd,
sh.declared_close_lbp,
sh.declared_close_usd - cm.net_usd as variance_usd,
sh.declared_close_lbp - cm.net_lbp as variance_lbp,
txn.txn_count,
txn.void_count,
txn.gross_usd,
txn.gross_lbp,
txn.fee_usd + txn.comm_usd as revenue_usd,
txn.fee_lbp + txn.comm_lbp as revenue_lbp
from app.shifts sh
join cm on cm.shift_id = sh.id
join txn on txn.shift_id = sh.id;
-- =====================================================================
-- Daily P&L per shop / service.
-- =====================================================================
create or replace view app.v_daily_pnl as
select
t.shop_id,
(t.occurred_at at time zone 'UTC')::date as day,
t.service_code,
count(*) filter (where t.status='completed') as txn_count,
sum(t.gross_usd) filter (where t.status='completed') as gross_usd,
sum(t.gross_lbp) filter (where t.status='completed') as gross_lbp,
sum(t.fee_usd) filter (where t.status='completed') as fee_usd,
sum(t.fee_lbp) filter (where t.status='completed') as fee_lbp,
sum(t.commission_usd) filter (where t.status='completed') as comm_usd,
sum(t.commission_lbp) filter (where t.status='completed') as comm_lbp,
count(*) filter (where t.status='voided') as void_count
from app.transactions t
group by t.shop_id, (t.occurred_at at time zone 'UTC')::date, t.service_code;
-- =====================================================================
-- Per-employee scorecard (last 30 days). Owner uses this to spot the
-- cashier whose numbers always look just slightly off.
-- =====================================================================
create or replace view app.v_employee_scorecard_30d as
with base as (
select sh.user_id as cashier_id, sh.shop_id, sh.id as shift_id,
(sh.declared_close_usd - z.expected_close_usd) as var_usd,
(sh.declared_close_lbp - z.expected_close_lbp) as var_lbp
from app.shifts sh
join app.v_z_report z on z.shift_id = sh.id
where sh.closed_at >= now() - interval '30 days'
and sh.status = 'closed'
),
voids as (
select t.shop_id, t.user_id as cashier_id,
count(*) as voids_30d,
count(*) filter (where t.voided_at - t.occurred_at > interval '10 minutes') as late_voids_30d
from app.transactions t
where t.status = 'voided'
and t.voided_at >= now() - interval '30 days'
group by t.shop_id, t.user_id
),
overrides as (
select t.shop_id, t.user_id as cashier_id,
count(*) as overrides_30d
from app.price_overrides p
join app.transactions t on t.id = p.txn_id
where p.created_at >= now() - interval '30 days'
group by t.shop_id, t.user_id
)
select
b.cashier_id,
b.shop_id,
count(*) as shifts_30d,
count(*) filter (where b.var_usd < 0) as short_shifts_usd,
count(*) filter (where b.var_lbp < 0) as short_shifts_lbp,
sum(b.var_usd) as total_var_usd,
sum(b.var_lbp) as total_var_lbp,
avg(b.var_usd) as avg_var_usd,
avg(b.var_lbp) as avg_var_lbp,
coalesce(v.voids_30d,0) as voids_30d,
coalesce(v.late_voids_30d,0) as late_voids_30d,
coalesce(o.overrides_30d,0) as overrides_30d
from base b
left join voids v on v.cashier_id = b.cashier_id and v.shop_id = b.shop_id
left join overrides o on o.cashier_id = b.cashier_id and o.shop_id = b.shop_id
group by b.cashier_id, b.shop_id, v.voids_30d, v.late_voids_30d, o.overrides_30d;
-- =====================================================================
-- Detector views (raw signals used by the alert engine).
-- =====================================================================
-- Recon backlog (vector #24)
create or replace view app.v_alert_recon_backlog as
select s.shop_id,
s.id as settlement_id,
s.provider,
s.period_start,
s.period_end,
count(e.id) as open_exceptions
from app.settlements s
join app.reconciliation_exceptions e on e.settlement_id = s.id and e.resolved_at is null
where s.status = 'has_exceptions'
group by s.shop_id, s.id, s.provider, s.period_start, s.period_end;
-- After-hours activity (vector #22)
create or replace view app.v_alert_after_hours as
select t.shop_id,
t.id as txn_id,
t.user_id as cashier_id,
t.occurred_at,
t.gross_usd, t.gross_lbp
from app.transactions t
where t.status = 'completed'
and (extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) < 7
or extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) >= 23);
-- Chronic short cashier (vector #2)
create or replace view app.v_alert_chronic_shorts as
select cashier_id, shop_id,
short_shifts_usd, short_shifts_lbp,
total_var_usd, total_var_lbp
from app.v_employee_scorecard_30d
where short_shifts_usd >= 5 or short_shifts_lbp >= 5
or total_var_usd <= -50 or total_var_lbp <= -1000000;
-- Void spike (vector #10) — >5 voids/day per cashier or any cashier with
-- voids_30d > 20.
create or replace view app.v_alert_void_spikes as
select t.shop_id, t.user_id as cashier_id,
(t.occurred_at at time zone 'Asia/Beirut')::date as day,
count(*) as void_count
from app.transactions t
where t.status = 'voided'
and t.voided_at >= now() - interval '30 days'
group by t.shop_id, t.user_id, (t.occurred_at at time zone 'Asia/Beirut')::date
having count(*) >= 5;
-- Override spike (vector #12)
create or replace view app.v_alert_override_spikes as
select t.shop_id, t.user_id as cashier_id,
(p.created_at at time zone 'Asia/Beirut')::date as day,
count(*) as override_count
from app.price_overrides p
join app.transactions t on t.id = p.txn_id
where p.created_at >= now() - interval '30 days'
group by t.shop_id, t.user_id, (p.created_at at time zone 'Asia/Beirut')::date
having count(*) >= 3;
-- Stock shrinkage (vector #13)
create or replace view app.v_alert_stock_shrinkage as
select s.shop_id, s.sku,
sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
then -m.qty_delta else 0 end) as shrink_qty_30d,
sum(case when m.type = 'sale_out' then -m.qty_delta else 0 end) as sales_qty_30d
from app.stock_movements m
join app.stock_on_hand s on s.shop_id = m.shop_id and s.sku = m.sku
where m.created_at >= now() - interval '30 days'
group by s.shop_id, s.sku
having sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
then -m.qty_delta else 0 end) >= 5;
-- Voucher loss / damage spike (vector #14)
create or replace view app.v_alert_voucher_writeoffs as
select v.shop_id,
v.sku,
count(*) filter (where v.status in ('damaged','lost')) as bad_30d,
count(*) as total_30d
from app.voucher_inventory v
where coalesce(v.sold_at, v.received_at) >= now() - interval '30 days'
group by v.shop_id, v.sku
having count(*) filter (where v.status in ('damaged','lost'))::numeric
/ nullif(count(*),0)::numeric > 0.02; -- > 2 %
-- =====================================================================
-- Persistent alerts table + detector engine
-- =====================================================================
do $$ begin
create type app.alert_severity as enum ('info','warn','critical');
exception when duplicate_object then null; end $$;
do $$ begin
create type app.alert_kind as enum (
'chronic_short',
'void_spike',
'override_spike',
'voucher_writeoffs',
'stock_shrinkage',
'after_hours',
'recon_backlog',
'aml_structuring',
'aml_burst',
'shift_unclosed',
'chain_break',
'reference_gap'
);
exception when duplicate_object then null; end $$;
create table if not exists app.alerts (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
kind app.alert_kind not null,
severity app.alert_severity not null default 'warn',
subject_id uuid, -- cashier / txn / settlement / shift
payload jsonb not null,
created_at timestamptz not null default now(),
acknowledged_at timestamptz,
acknowledged_by uuid references auth.users(id),
ack_note text,
-- Avoid duplicate alerts for the same condition on the same day:
dedupe_key text not null unique
);
create index if not exists idx_alerts_open on app.alerts(shop_id, kind)
where acknowledged_at is null;
-- Append-only / controlled update.
create or replace function app._alerts_guard()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'alerts cannot be deleted';
end if;
if current_setting('app.alerts_internal', true) is distinct from 'on' then
raise exception 'alerts can only be modified via app.* functions';
end if;
return new;
end;
$$;
drop trigger if exists trg_alerts_guard on app.alerts;
create trigger trg_alerts_guard before update or delete on app.alerts
for each row execute function app._alerts_guard();
create or replace function app._raise_alert(
p_shop uuid, p_kind app.alert_kind, p_severity app.alert_severity,
p_subject uuid, p_payload jsonb, p_dedupe text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare aid uuid;
begin
insert into app.alerts(shop_id, kind, severity, subject_id, payload, dedupe_key)
values (p_shop, p_kind, p_severity, p_subject, p_payload, p_dedupe)
on conflict (dedupe_key) do nothing
returning id into aid;
return aid;
end;
$$;
-- The detector. Idempotent: each rule produces a deterministic
-- `dedupe_key` so re-running it doesn't multiply alerts.
create or replace function app.run_alert_detectors()
returns int
language plpgsql
security definer
set search_path = app, public
as $$
declare n int := 0; r record;
begin
-- Chronic shorts (vector #2)
for r in select * from app.v_alert_chronic_shorts loop
if app._raise_alert(r.shop_id, 'chronic_short', 'critical',
r.cashier_id,
jsonb_build_object('short_usd_shifts', r.short_shifts_usd,
'short_lbp_shifts', r.short_shifts_lbp,
'total_var_usd', r.total_var_usd,
'total_var_lbp', r.total_var_lbp),
format('chronic_short:%s:%s:%s', r.shop_id, r.cashier_id, to_char(now(),'YYYYMMDD'))
) is not null then n := n + 1; end if;
end loop;
-- Void spikes (vector #10)
for r in select * from app.v_alert_void_spikes loop
if app._raise_alert(r.shop_id, 'void_spike', 'warn',
r.cashier_id,
jsonb_build_object('day', r.day, 'count', r.void_count),
format('void_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
) is not null then n := n + 1; end if;
end loop;
-- Override spikes (vector #12)
for r in select * from app.v_alert_override_spikes loop
if app._raise_alert(r.shop_id, 'override_spike', 'warn',
r.cashier_id,
jsonb_build_object('day', r.day, 'count', r.override_count),
format('override_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
) is not null then n := n + 1; end if;
end loop;
-- Voucher write-off rate (vector #14)
for r in select * from app.v_alert_voucher_writeoffs loop
if app._raise_alert(r.shop_id, 'voucher_writeoffs', 'critical',
null,
jsonb_build_object('sku', r.sku, 'bad_30d', r.bad_30d, 'total_30d', r.total_30d),
format('voucher_writeoffs:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
) is not null then n := n + 1; end if;
end loop;
-- Stock shrinkage (vector #13)
for r in select * from app.v_alert_stock_shrinkage loop
if app._raise_alert(r.shop_id, 'stock_shrinkage', 'warn',
null,
jsonb_build_object('sku', r.sku, 'shrink_qty_30d', r.shrink_qty_30d, 'sales_qty_30d', r.sales_qty_30d),
format('stock_shrinkage:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
) is not null then n := n + 1; end if;
end loop;
-- After-hours (vector #22) — bucket per cashier per day
for r in
select shop_id, cashier_id,
(occurred_at at time zone 'Asia/Beirut')::date as day,
count(*) as cnt,
sum(coalesce(gross_usd,0)) as g_usd,
sum(coalesce(gross_lbp,0)) as g_lbp
from app.v_alert_after_hours
where occurred_at >= now() - interval '7 days'
group by shop_id, cashier_id, (occurred_at at time zone 'Asia/Beirut')::date
loop
if app._raise_alert(r.shop_id, 'after_hours', 'warn',
r.cashier_id,
jsonb_build_object('day', r.day, 'count', r.cnt,
'gross_usd', r.g_usd, 'gross_lbp', r.g_lbp),
format('after_hours:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
) is not null then n := n + 1; end if;
end loop;
-- Recon backlog (vector #24)
for r in select * from app.v_alert_recon_backlog loop
if app._raise_alert(r.shop_id, 'recon_backlog', 'critical',
r.settlement_id,
jsonb_build_object('provider', r.provider,
'period_start', r.period_start,
'period_end', r.period_end,
'open_exceptions', r.open_exceptions),
format('recon_backlog:%s', r.settlement_id)
) is not null then n := n + 1; end if;
end loop;
-- AML signals (from 0006)
for r in select * from app.v_aml_structuring_by_customer loop
if app._raise_alert(r.shop_id, 'aml_structuring', 'critical',
r.customer_id,
jsonb_build_object('day', r.day, 'service', r.service_code,
'cnt', r.cnt, 'sum_usd', r.sum_usd, 'sum_lbp', r.sum_lbp),
format('aml_structuring:%s:%s:%s:%s', r.shop_id, r.customer_id, r.service_code, r.day)
) is not null then n := n + 1; end if;
end loop;
for r in select * from app.v_aml_same_beneficiary_burst loop
if app._raise_alert(r.shop_id, 'aml_burst', 'critical',
null,
jsonb_build_object('beneficiary_phone', r.beneficiary_phone,
'window_hour', r.window_hour,
'cashier_count', r.cashier_count, 'cnt', r.cnt),
format('aml_burst:%s:%s:%s', r.shop_id, r.beneficiary_phone, r.window_hour)
) is not null then n := n + 1; end if;
end loop;
-- Shift left open > 18 hours (vector #4)
for r in
select id, shop_id, cashier_id, opened_at
from app.shifts
where status = 'open' and opened_at < now() - interval '18 hours'
loop
if app._raise_alert(r.shop_id, 'shift_unclosed', 'warn',
r.cashier_id,
jsonb_build_object('shift_id', r.id, 'opened_at', r.opened_at),
format('shift_unclosed:%s', r.id)
) is not null then n := n + 1; end if;
end loop;
-- Reference number gaps (vector #20)
for r in select * from app.v_reference_gaps loop
if app._raise_alert(r.shop_id, 'reference_gap', 'critical',
null,
jsonb_build_object('expected', r.expected_ref, 'actual', r.actual_ref),
format('reference_gap:%s:%s', r.shop_id, r.expected_ref)
) is not null then n := n + 1; end if;
end loop;
-- Hash chain break (vector #25) — verify per shop, raise if any row fails
for r in
select s.id as shop_id
from app.shops s
where exists (select 1 from app.verify_chain(s.id) v where v.ok = false)
loop
if app._raise_alert(r.shop_id, 'chain_break', 'critical',
null,
jsonb_build_object('detected_at', now()),
format('chain_break:%s:%s', r.shop_id, to_char(now(),'YYYYMMDDHH24'))
) is not null then n := n + 1; end if;
end loop;
return n;
end;
$$;
revoke all on function app.run_alert_detectors() from public;
grant execute on function app.run_alert_detectors() to authenticated;
-- Acknowledge an alert (owner only, audited).
create or replace function app.ack_alert(p_alert uuid, p_note text)
returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare a app.alerts%rowtype;
begin
if p_note is null or length(btrim(p_note)) < 5 then
raise exception 'ack note >= 5 chars required';
end if;
select * into a from app.alerts where id = p_alert;
if a.id is null then raise exception 'alert not found'; end if;
if not app.has_role_in_shop(a.shop_id, 'owner') then
raise exception 'owner role required';
end if;
if a.acknowledged_at is not null then
raise exception 'alert already acknowledged';
end if;
perform set_config('app.alerts_internal', 'on', true);
update app.alerts
set acknowledged_at = now(), acknowledged_by = auth.uid(), ack_note = p_note
where id = p_alert;
perform set_config('app.alerts_internal', 'off', true);
perform app.log_auth_event('alert_ack', a.shop_id, null,
jsonb_build_object('alert_id', p_alert, 'kind', a.kind));
end;
$$;
revoke all on function app.ack_alert(uuid, text) from public;
grant execute on function app.ack_alert(uuid, text) to authenticated;
-- =====================================================================
-- Owner dashboard rollup
-- =====================================================================
create or replace view app.v_owner_dashboard as
select
s.id as shop_id,
s.name as shop_name,
(select count(*) from app.shifts where shop_id=s.id and status='open') as open_shifts,
(select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null) as open_alerts,
(select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null
and severity='critical') as critical_alerts,
(select count(*) from app.reconciliation_exceptions e
join app.settlements st on st.id=e.settlement_id
where st.shop_id=s.id and e.resolved_at is null) as open_recon_exceptions,
(select coalesce(sum(gross_usd),0) from app.v_daily_pnl
where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date) as today_gross_usd,
(select coalesce(sum(gross_lbp),0) from app.v_daily_pnl
where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date) as today_gross_lbp
from app.shops s;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.alerts enable row level security;
alter table app.alerts force row level security;
revoke insert, update, delete on app.alerts from authenticated;
drop policy if exists alerts_select on app.alerts;
create policy alerts_select on app.alerts
for select to authenticated
using (
app.has_any_role_in_shop(shop_id,
array['owner','manager','auditor']::app.business_role[])
);
grant select on app.alerts to authenticated;
-- End migration 0010 ----------------------------------------------------
+222
View File
@@ -0,0 +1,222 @@
-- =====================================================================
-- Migration 0011 — Hardening & ops (roadmap Step 12).
--
-- 1. pg_cron schedules: alert detector + chain verifier.
-- 2. Daily off-site hash anchor (writes the day's last row_hash per shop
-- to app.daily_anchors; an external job copies these to S3/Glacier).
-- 3. HMAC + PIN secret rotation procedures with audit.
-- 4. DDL lockdown advisory (event trigger blocking DDL by anyone other
-- than the migration role).
-- 5. NTP / clock-skew guard at INSERT time on app.transactions.
--
-- Threat-model rows: 4, 7, 17, 22, 23, 25.
-- =====================================================================
-- ---------------------------------------------------------------------
-- 1. pg_cron schedules. Supabase ships pg_cron in the `extensions`
-- schema. Each task runs as the table owner thanks to SECURITY DEFINER.
-- ---------------------------------------------------------------------
create extension if not exists pg_cron;
-- Run alert detectors every 5 minutes.
do $$ begin
perform cron.schedule('app_alert_detectors_5m',
'*/5 * * * *',
$cmd$ select app.run_alert_detectors(); $cmd$);
exception when others then null; -- already scheduled
end $$;
-- Verify the hash chain hourly per shop. We don't bail loudly here;
-- run_alert_detectors() raises a chain_break alert if verify_chain fails.
do $$ begin
perform cron.schedule('app_chain_verify_hourly',
'7 * * * *',
$cmd$ select 1 from (
select s.id, (select bool_and(ok) from app.verify_chain(s.id))
from app.shops s
) v; $cmd$);
exception when others then null;
end $$;
-- ---------------------------------------------------------------------
-- 2. Daily off-site anchor.
-- The most important fraud control after recon: every night, copy
-- the last row_hash per shop into a row that is written ONCE,
-- timestamped, and exported to immutable storage. If anyone tampers
-- with history, today's anchor will not chain back to yesterday's.
-- ---------------------------------------------------------------------
create table if not exists app.daily_anchors (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete restrict,
anchor_date date not null,
last_txn_id uuid,
last_ref_no bigint,
last_row_hash bytea,
txn_count_to_date bigint not null,
computed_at timestamptz not null default now(),
unique (shop_id, anchor_date)
);
alter table app.daily_anchors enable row level security;
alter table app.daily_anchors force row level security;
revoke insert, update, delete on app.daily_anchors from authenticated;
create or replace function app._daily_anchors_guard()
returns trigger language plpgsql as $$
begin
raise exception 'daily_anchors are append-only';
end;
$$;
drop trigger if exists trg_daily_anchors_guard on app.daily_anchors;
create trigger trg_daily_anchors_guard before update or delete on app.daily_anchors
for each row execute function app._daily_anchors_guard();
drop policy if exists daily_anchors_select on app.daily_anchors;
create policy daily_anchors_select on app.daily_anchors
for select to authenticated
using (app.has_any_role_in_shop(shop_id,
array['owner','auditor']::app.business_role[]));
grant select on app.daily_anchors to authenticated;
create or replace function app.write_daily_anchors()
returns int
language plpgsql
security definer
set search_path = app, public
as $$
declare n int := 0; r record;
begin
for r in
with last_row as (
select distinct on (shop_id)
shop_id, id, reference_no, row_hash, occurred_at
from app.transactions
where (occurred_at at time zone 'UTC')::date
= (now() at time zone 'UTC')::date - 1
order by shop_id, reference_no desc
)
select lr.shop_id, lr.id, lr.reference_no, lr.row_hash,
(now() at time zone 'UTC')::date - 1 as anchor_date,
(select count(*) from app.transactions t
where t.shop_id = lr.shop_id
and t.reference_no <= lr.reference_no) as cnt
from last_row lr
loop
insert into app.daily_anchors(shop_id, anchor_date, last_txn_id,
last_ref_no, last_row_hash, txn_count_to_date)
values (r.shop_id, r.anchor_date, r.id,
r.reference_no, r.row_hash, r.cnt)
on conflict (shop_id, anchor_date) do nothing;
n := n + 1;
end loop;
perform app.log_auth_event('daily_anchor_written', null, null,
jsonb_build_object('rows', n));
return n;
end;
$$;
revoke all on function app.write_daily_anchors() from public;
grant execute on function app.write_daily_anchors() to authenticated;
-- 02:15 Beirut time = 23:15 UTC the previous day; at that hour the till
-- is closed and the day's last txn already exists.
do $$ begin
perform cron.schedule('app_daily_anchor',
'15 23 * * *',
$cmd$ select app.write_daily_anchors(); $cmd$);
exception when others then null;
end $$;
-- ---------------------------------------------------------------------
-- 3. Secret rotation with audit. The HMAC key was created in 0007;
-- rotating it invalidates all printed receipts but new ones become
-- forgery-resistant. PIN rotation is per-user.
-- ---------------------------------------------------------------------
create table if not exists app.secret_rotations (
id uuid primary key default gen_random_uuid(),
secret_name text not null,
rotated_by uuid not null references auth.users(id) default auth.uid(),
rotated_at timestamptz not null default now(),
reason text not null check (length(btrim(reason)) >= 5)
);
alter table app.secret_rotations enable row level security;
alter table app.secret_rotations force row level security;
revoke insert, update, delete on app.secret_rotations from authenticated;
drop policy if exists secret_rotations_select on app.secret_rotations;
create policy secret_rotations_select on app.secret_rotations
for select to authenticated
using (app.is_owner_anywhere());
grant select on app.secret_rotations to authenticated;
create or replace function app._secret_rotations_guard()
returns trigger language plpgsql as $$
begin
raise exception 'secret_rotations are append-only';
end;
$$;
drop trigger if exists trg_secret_rotations_guard on app.secret_rotations;
create trigger trg_secret_rotations_guard before update or delete on app.secret_rotations
for each row execute function app._secret_rotations_guard();
create or replace function app.log_secret_rotation(p_name text, p_reason text)
returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare rid uuid;
begin
if not app.is_owner_anywhere() then
raise exception 'owner role required';
end if;
insert into app.secret_rotations(secret_name, reason)
values (p_name, p_reason) returning id into rid;
return rid;
end;
$$;
revoke all on function app.log_secret_rotation(text, text) from public;
grant execute on function app.log_secret_rotation(text, text) to authenticated;
-- ---------------------------------------------------------------------
-- 4. DDL lockdown advisory.
-- Anyone with `authenticated` should not be able to issue DDL anyway,
-- but Supabase ships a `service_role` key. This event trigger raises
-- if DDL is attempted from anything other than the migration owner.
-- ---------------------------------------------------------------------
create or replace function app._ddl_lock()
returns event_trigger
language plpgsql
as $$
begin
-- Allow the role that owns the schema (typically `postgres` running
-- supabase migrations) and the cron worker. Block everyone else.
if current_user not in ('postgres', 'supabase_admin') then
raise exception 'DDL is locked: caller % may not modify schema', current_user;
end if;
end;
$$;
drop event trigger if exists app_ddl_lock;
create event trigger app_ddl_lock
on ddl_command_start
execute function app._ddl_lock();
-- ---------------------------------------------------------------------
-- 5. Clock-skew guard. A till with a manipulated clock can backdate or
-- pre-date transactions to hide them from a shift. Reject inserts
-- whose `occurred_at` is more than 5 minutes off server `now()`.
-- ---------------------------------------------------------------------
create or replace function app._txn_clock_guard()
returns trigger language plpgsql as $$
begin
if abs(extract(epoch from (new.occurred_at - now()))) > 300 then
raise exception
'clock skew rejected: occurred_at=% server now()=%', new.occurred_at, now();
end if;
return new;
end;
$$;
drop trigger if exists trg_txn_clock_guard on app.transactions;
-- Fires before the existing txn_before_insert (alphabetical 'a' < 't').
create trigger trg_a_txn_clock_guard before insert on app.transactions
for each row execute function app._txn_clock_guard();
-- End migration 0011 ----------------------------------------------------
@@ -0,0 +1,667 @@
-- =====================================================================
-- Migration 0013 — Step 13a corrections + Step 13b record-RPCs.
--
-- 1. Replaces views in 0010 / 0006 / 0012 that referenced columns that
-- don't exist in the actual schema (cashier_id, opening_float_*,
-- services.requires_*, AML view shop_id, etc.).
-- 2. Defines the public API the React UI actually needs:
-- - app.me() -> current user profile + roles
-- - app.v_my_shops -> shops the caller belongs to
-- - app.my_open_shift(shop) -> the caller's open shift
-- - app.v_services_active -> service catalog
-- - app.v_my_recent_transactions
-- 3. Defines `record_*` SECURITY DEFINER helpers, one per service
-- family, that insert the parent transaction row AND the matching
-- detail row in a single round-trip. The deferred constraint trigger
-- from 0004 fires at COMMIT and would otherwise reject any client
-- pattern that tried to do those two writes in separate HTTP calls.
-- 4. Seeds app.services with the 12 codes the UI uses.
-- 5. Revokes INSERT on app.transactions from authenticated; the only
-- legal path is one of the record_* functions defined here.
--
-- Threat-model rows: 1, 5, 6, 8, 19, 21.
-- =====================================================================
-- =====================================================================
-- Service catalog seed (idempotent)
-- =====================================================================
insert into app.services(code, name, category) values
('OMT_SEND', 'OMT — Send', 'money_transfer'),
('OMT_RECEIVE', 'OMT — Receive', 'money_transfer'),
('OMT_BILL', 'Bill payment via OMT', 'bills'),
('WU_SEND', 'Western Union — Send', 'money_transfer'),
('WU_RECEIVE', 'Western Union — Recv', 'money_transfer'),
('WHISH_SEND', 'Whish — Send', 'money_transfer'),
('ALFA_RECHARGE', 'Alfa recharge', 'telecom_recharge'),
('TOUCH_RECHARGE', 'touch recharge', 'telecom_recharge'),
('OGERO_RECHARGE', 'Ogero recharge', 'telecom_recharge'),
('INTERNET_RECHARGE', 'Internet voucher', 'telecom_recharge'),
('EDL_BILL', 'EDL electricity bill', 'bills'),
('GOODS_SALE', 'Goods sale', 'goods'),
('REPAIR', 'Phone / device repair', 'repair'),
('REFUND', 'Refund', 'refund')
on conflict (code) do update set name = excluded.name, category = excluded.category;
-- =====================================================================
-- Read views the UI consumes
-- =====================================================================
drop view if exists app.v_services_active;
create view app.v_services_active as
select code, name, category, is_active
from app.services
where is_active = true
order by category, name;
grant select on app.v_services_active to authenticated;
drop view if exists app.v_my_shops;
create view app.v_my_shops as
select s.id as shop_id, s.name, a.role
from app.shops s
join app.user_shop_assignments a
on a.shop_id = s.id and a.user_id = auth.uid();
grant select on app.v_my_shops to authenticated;
drop view if exists app.v_my_tills;
create view app.v_my_tills as
select t.id as till_id, t.shop_id, t.name, t.is_active
from app.tills t
where t.is_active
and exists (
select 1 from app.user_shop_assignments a
where a.shop_id = t.shop_id and a.user_id = auth.uid()
);
grant select on app.v_my_tills to authenticated;
drop view if exists app.v_my_recent_transactions;
create view app.v_my_recent_transactions as
select t.id, t.reference_no, t.shop_id, t.till_id, t.shift_id,
t.service_code, s.name as service_name, s.category,
t.payment_method,
t.gross_usd, t.gross_lbp,
t.fee_usd + t.commission_usd as revenue_usd,
t.fee_lbp + t.commission_lbp as revenue_lbp,
t.external_ref, t.external_ref_provider,
t.beneficiary_name, t.beneficiary_phone,
t.status, t.occurred_at, t.user_id
from app.transactions t
join app.services s on s.code = t.service_code
where t.user_id = auth.uid()
or app.has_any_role_in_shop(t.shop_id,
array['owner','manager','auditor']::app.business_role[]);
grant select on app.v_my_recent_transactions to authenticated;
-- "Who am I" — single round-trip for the auth bootstrap.
create or replace function app.me()
returns table (
user_id uuid,
full_name text,
is_active boolean,
is_owner_anywhere boolean,
shops jsonb
) language sql
security definer
set search_path = app, public
stable
as $$
select
auth.uid() as user_id,
coalesce(p.full_name, '') as full_name,
coalesce(p.is_active, true) as is_active,
app.is_owner_anywhere() as is_owner_anywhere,
coalesce((
select jsonb_agg(jsonb_build_object(
'shop_id', a.shop_id, 'shop_name', s.name, 'role', a.role))
from app.user_shop_assignments a
join app.shops s on s.id = a.shop_id
where a.user_id = auth.uid()
), '[]'::jsonb) as shops
from app.user_profiles p
where p.user_id = auth.uid()
union all
-- profile may not exist yet; return one row anyway
select auth.uid(), '', true,
app.is_owner_anywhere(),
coalesce((
select jsonb_agg(jsonb_build_object(
'shop_id', a.shop_id, 'shop_name', s.name, 'role', a.role))
from app.user_shop_assignments a
join app.shops s on s.id = a.shop_id
where a.user_id = auth.uid()
), '[]'::jsonb)
where not exists (select 1 from app.user_profiles where user_id = auth.uid())
limit 1;
$$;
revoke all on function app.me() from public;
grant execute on function app.me() to authenticated;
-- The caller's open shift in a given shop. Used by the till UI to
-- decide whether the "New transaction" button is enabled.
create or replace function app.my_open_shift(p_shop uuid)
returns table (
shift_id uuid,
till_id uuid,
opened_at timestamptz,
status app.shift_status,
opening_usd numeric,
opening_lbp numeric
) language sql
security definer
set search_path = app, public
stable
as $$
select id, till_id, opened_at, status, opening_usd, opening_lbp
from app.shifts
where shop_id = p_shop
and user_id = auth.uid()
and status = 'open'
order by opened_at desc
limit 1;
$$;
revoke all on function app.my_open_shift(uuid) from public;
grant execute on function app.my_open_shift(uuid) to authenticated;
-- =====================================================================
-- Internal: insert a parent transaction row.
-- All record_* wrappers below call this and then insert the detail row
-- in the same DB transaction so the deferred constraint trigger from
-- 0004 (txn must have a detail at COMMIT) is satisfied.
-- =====================================================================
create or replace function app._insert_txn(
p_shop uuid,
p_till uuid,
p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric,
p_gross_lbp numeric,
p_fee_usd numeric,
p_fee_lbp numeric,
p_commission_usd numeric,
p_commission_lbp numeric,
p_fx_rate_used numeric,
p_external_ref_provider text,
p_external_ref text,
p_beneficiary_name text,
p_beneficiary_phone text,
p_customer_id uuid,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shift uuid; v_tid uuid;
begin
-- Role check
if not app.has_any_role_in_shop(p_shop,
array['owner','manager','cashier']::app.business_role[]) then
raise exception 'no role in shop %', p_shop;
end if;
-- Service must exist & be active
if not exists (select 1 from app.services
where code = p_service_code and is_active) then
raise exception 'unknown or inactive service %', p_service_code;
end if;
-- Amounts
if coalesce(p_gross_usd,0) < 0 or coalesce(p_gross_lbp,0) < 0
or coalesce(p_fee_usd,0) < 0 or coalesce(p_fee_lbp,0) < 0
or coalesce(p_commission_usd,0) < 0 or coalesce(p_commission_lbp,0) < 0 then
raise exception 'amounts must be non-negative';
end if;
-- Open shift
select id into v_shift
from app.shifts
where shop_id = p_shop and till_id = p_till
and user_id = auth.uid() and status = 'open'
order by opened_at desc limit 1;
if v_shift is null then
raise exception 'no open shift for caller in shop %, till %', p_shop, p_till;
end if;
insert into app.transactions(
shop_id, till_id, shift_id, user_id, service_code, status,
payment_method,
gross_usd, gross_lbp, fee_usd, fee_lbp,
commission_usd, commission_lbp, fx_rate_used,
external_ref_provider, external_ref,
beneficiary_name, beneficiary_phone,
customer_id, notes,
created_by,
-- placeholder; the BEFORE INSERT hash trigger from 0003 fills these
row_hash
) values (
p_shop, p_till, v_shift, auth.uid(), p_service_code, 'completed',
p_payment_method,
coalesce(p_gross_usd,0), coalesce(p_gross_lbp,0),
coalesce(p_fee_usd,0), coalesce(p_fee_lbp,0),
coalesce(p_commission_usd,0), coalesce(p_commission_lbp,0),
p_fx_rate_used,
p_external_ref_provider, p_external_ref,
p_beneficiary_name, p_beneficiary_phone,
p_customer_id, p_notes,
auth.uid(),
decode('00','hex') -- the BEFORE INSERT trigger overwrites this
) returning id into v_tid;
return v_tid;
end;
$$;
-- Internal helper; not granted to clients.
revoke all on function app._insert_txn(
uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric,
numeric, text, text, text, text, uuid, text) from public;
-- Lock down direct INSERT — only the record_* wrappers may write.
revoke insert on app.transactions from authenticated;
revoke insert on app.omt_send_details, app.omt_receive_details,
app.bill_payment_details, app.recharge_details,
app.goods_sale_details, app.repair_details
from authenticated;
-- =====================================================================
-- record_recharge: Alfa / touch / Ogero / Internet voucher OR e-recharge
-- =====================================================================
create or replace function app.record_recharge(
p_shop uuid, p_till uuid, p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_fx_rate numeric,
p_operator text, p_msisdn text, p_product_code text,
p_voucher_serial text, p_e_recharge_ref text,
p_unit_face_usd numeric, p_unit_cost_usd numeric,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
p_operator, p_voucher_serial, null, p_msisdn, null, p_notes);
insert into app.recharge_details(
txn_id, operator, msisdn, product_code,
voucher_serial, e_recharge_provider_ref,
unit_face_value_usd, unit_cost_usd
) values (
v_txn, p_operator, p_msisdn, p_product_code,
nullif(btrim(p_voucher_serial),''),
nullif(btrim(p_e_recharge_ref),''),
p_unit_face_usd, p_unit_cost_usd
);
return v_txn;
end; $$;
revoke all on function app.record_recharge(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric,
text, text, text, text, text, numeric, numeric, text) from public;
grant execute on function app.record_recharge(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric,
text, text, text, text, text, numeric, numeric, text) to authenticated;
-- =====================================================================
-- record_omt_send
-- =====================================================================
create or replace function app.record_omt_send(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_direction app.transfer_direction,
p_sender_full_name text, p_sender_id_type app.id_doc_type,
p_sender_id_number text, p_sender_phone text,
p_sender_dob date, p_sender_nationality text,
p_beneficiary_full_name text, p_beneficiary_phone text,
p_destination_country text,
p_purpose_code text, p_purpose_note text,
p_kyc_doc_url text,
p_customer_id uuid,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, 'OMT_SEND', p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
'OMT', p_external_ref,
p_beneficiary_full_name, p_beneficiary_phone,
p_customer_id, p_notes);
insert into app.omt_send_details(
txn_id, direction,
sender_full_name, sender_id_type, sender_id_number, sender_phone,
sender_dob, sender_nationality,
beneficiary_full_name, beneficiary_phone, destination_country,
purpose_code, purpose_note, kyc_doc_url
) values (
v_txn, p_direction,
p_sender_full_name, p_sender_id_type, p_sender_id_number, p_sender_phone,
p_sender_dob, p_sender_nationality,
p_beneficiary_full_name, p_beneficiary_phone, p_destination_country,
p_purpose_code, p_purpose_note, p_kyc_doc_url
);
return v_txn;
end; $$;
revoke all on function app.record_omt_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, date, text,
text, text, text, text, text, text, uuid, text) from public;
grant execute on function app.record_omt_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, date, text,
text, text, text, text, text, text, uuid, text) to authenticated;
-- =====================================================================
-- record_omt_receive (payout)
-- =====================================================================
create or replace function app.record_omt_receive(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_payout_code text,
p_beneficiary_full_name text,
p_beneficiary_id_type app.id_doc_type,
p_beneficiary_id_number text,
p_beneficiary_phone text,
p_origin_country text,
p_kyc_doc_url text,
p_customer_id uuid,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, 'OMT_RECEIVE', p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
'OMT', p_payout_code,
p_beneficiary_full_name, p_beneficiary_phone,
p_customer_id, p_notes);
insert into app.omt_receive_details(
txn_id, payout_code,
beneficiary_full_name, beneficiary_id_type, beneficiary_id_number,
beneficiary_phone, origin_country, kyc_doc_url
) values (
v_txn, p_payout_code,
p_beneficiary_full_name, p_beneficiary_id_type, p_beneficiary_id_number,
p_beneficiary_phone, p_origin_country, p_kyc_doc_url
);
return v_txn;
end; $$;
revoke all on function app.record_omt_receive(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, text, app.id_doc_type, text, text, text, text, uuid, text) from public;
grant execute on function app.record_omt_receive(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, text, app.id_doc_type, text, text, text, text, uuid, text) to authenticated;
-- =====================================================================
-- record_bill (OMT_BILL / EDL_BILL)
-- =====================================================================
create or replace function app.record_bill(
p_shop uuid, p_till uuid, p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_biller_code text, p_account_number text,
p_period text, p_customer_name text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
if p_service_code not in ('OMT_BILL','EDL_BILL') then
raise exception 'record_bill only for bill services, got %', p_service_code;
end if;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
p_biller_code, p_external_ref,
p_customer_name, null,
p_customer_id, p_notes);
insert into app.bill_payment_details(
txn_id, biller_code, account_number, period, customer_name
) values (
v_txn, p_biller_code, p_account_number, p_period, p_customer_name
);
return v_txn;
end; $$;
revoke all on function app.record_bill(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text) from public;
grant execute on function app.record_bill(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text) to authenticated;
-- =====================================================================
-- record_goods_sale (single line)
-- =====================================================================
create or replace function app.record_goods_sale(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fx_rate numeric,
p_sku text, p_qty integer,
p_unit_cost_usd numeric, p_unit_price_usd numeric,
p_serial_number text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
if p_qty is null or p_qty <= 0 then
raise exception 'qty must be > 0';
end if;
v_txn := app._insert_txn(p_shop, p_till, 'GOODS_SALE', p_payment_method,
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, p_customer_id, p_notes);
insert into app.goods_sale_details(
txn_id, sku, qty, unit_cost_usd, unit_price_usd, serial_number
) values (
v_txn, p_sku, p_qty, p_unit_cost_usd, p_unit_price_usd, p_serial_number
);
return v_txn;
end; $$;
revoke all on function app.record_goods_sale(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) from public;
grant execute on function app.record_goods_sale(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) to authenticated;
-- =====================================================================
-- record_repair
-- =====================================================================
create or replace function app.record_repair(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fx_rate numeric,
p_device_type text, p_device_imei text,
p_issue_summary text, p_warranty_days integer,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, 'REPAIR', p_payment_method,
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, p_customer_id, p_notes);
insert into app.repair_details(
txn_id, device_type, device_imei, issue_summary, warranty_days
) values (
v_txn, p_device_type, p_device_imei, p_issue_summary,
coalesce(p_warranty_days, 0)
);
return v_txn;
end; $$;
revoke all on function app.record_repair(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, text, text, integer, uuid, text) from public;
grant execute on function app.record_repair(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, text, text, integer, uuid, text) to authenticated;
-- =====================================================================
-- Re-define the broken views from 0010 against the actual schema.
-- =====================================================================
drop view if exists app.v_z_report cascade;
create view app.v_z_report as
with cm as (
select sh.id as shift_id,
coalesce(sum(amount) filter (where currency='USD'),0) as net_usd,
coalesce(sum(amount) filter (where currency='LBP'),0) as net_lbp
from app.shifts sh
left join app.cash_movements m on m.shift_id = sh.id
group by sh.id
),
txn as (
select sh.id as shift_id,
count(*) filter (where t.status='completed') as txn_count,
count(*) filter (where t.status='voided') as void_count,
coalesce(sum(t.gross_usd) filter (where t.status='completed'),0) as gross_usd,
coalesce(sum(t.gross_lbp) filter (where t.status='completed'),0) as gross_lbp,
coalesce(sum(t.fee_usd) filter (where t.status='completed'),0) as fee_usd,
coalesce(sum(t.fee_lbp) filter (where t.status='completed'),0) as fee_lbp,
coalesce(sum(t.commission_usd) filter (where t.status='completed'),0) as comm_usd,
coalesce(sum(t.commission_lbp) filter (where t.status='completed'),0) as comm_lbp
from app.shifts sh
left join app.transactions t on t.shift_id = sh.id
group by sh.id
)
select
sh.id as shift_id, sh.shop_id, sh.till_id,
sh.user_id as cashier_id,
sh.opened_at, sh.closed_at, sh.status,
sh.opening_usd, sh.opening_lbp,
cm.net_usd as expected_close_usd,
cm.net_lbp as expected_close_lbp,
sh.declared_close_usd, sh.declared_close_lbp,
coalesce(sh.declared_close_usd, 0) - cm.net_usd as variance_usd,
coalesce(sh.declared_close_lbp, 0) - cm.net_lbp as variance_lbp,
txn.txn_count, txn.void_count,
txn.gross_usd, txn.gross_lbp,
txn.fee_usd + txn.comm_usd as revenue_usd,
txn.fee_lbp + txn.comm_lbp as revenue_lbp
from app.shifts sh
join cm on cm.shift_id = sh.id
join txn on txn.shift_id = sh.id;
grant select on app.v_z_report to authenticated;
drop view if exists app.v_employee_scorecard_30d cascade;
create view app.v_employee_scorecard_30d as
with base as (
select sh.user_id as cashier_id, sh.shop_id, sh.id as shift_id,
(coalesce(sh.declared_close_usd,0) - z.expected_close_usd) as var_usd,
(coalesce(sh.declared_close_lbp,0) - z.expected_close_lbp) as var_lbp
from app.shifts sh
join app.v_z_report z on z.shift_id = sh.id
where sh.closed_at >= now() - interval '30 days'
and sh.status = 'closed'
),
voids as (
select t.shop_id, t.user_id as cashier_id,
count(*) as voids_30d,
count(*) filter (where t.voided_at - t.occurred_at > interval '10 minutes')
as late_voids_30d
from app.transactions t
where t.status = 'voided' and t.voided_at >= now() - interval '30 days'
group by t.shop_id, t.user_id
),
ovr as (
select t.shop_id, t.user_id as cashier_id,
count(*) as overrides_30d
from app.price_overrides p
join app.transactions t on t.id = p.txn_id
where p.created_at >= now() - interval '30 days'
group by t.shop_id, t.user_id
)
select
b.cashier_id, b.shop_id,
count(*) as shifts_30d,
count(*) filter (where b.var_usd < 0) as short_shifts_usd,
count(*) filter (where b.var_lbp < 0) as short_shifts_lbp,
sum(b.var_usd) as total_var_usd,
sum(b.var_lbp) as total_var_lbp,
avg(b.var_usd) as avg_var_usd,
avg(b.var_lbp) as avg_var_lbp,
coalesce(v.voids_30d, 0) as voids_30d,
coalesce(v.late_voids_30d, 0) as late_voids_30d,
coalesce(o.overrides_30d, 0) as overrides_30d
from base b
left join voids v on v.cashier_id = b.cashier_id and v.shop_id = b.shop_id
left join ovr o on o.cashier_id = b.cashier_id and o.shop_id = b.shop_id
group by b.cashier_id, b.shop_id, v.voids_30d, v.late_voids_30d, o.overrides_30d;
grant select on app.v_employee_scorecard_30d to authenticated;
-- AML views: re-create with shop_id and aliases the detector loop expects.
drop view if exists app.v_aml_structuring_by_customer cascade;
create view app.v_aml_structuring_by_customer as
with d as (
select t.shop_id, t.customer_id, t.service_code,
(t.occurred_at at time zone 'UTC')::date as day,
count(*) as cnt,
sum(t.gross_usd) as sum_usd,
sum(t.gross_lbp) as sum_lbp
from app.transactions t
where t.status = 'completed'
and t.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE')
and t.customer_id is not null
group by 1,2,3,4
)
select d.*
from d
where d.cnt >= 3
and (
d.sum_usd >= 0.8 * coalesce(
(select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'USD'), 1e18)
or d.sum_lbp >= 0.8 * coalesce(
(select daily_amount_warn from app.kyc_thresholds
where service_code = d.service_code and currency = 'LBP'), 1e18));
grant select on app.v_aml_structuring_by_customer to authenticated;
drop view if exists app.v_aml_same_beneficiary_burst cascade;
create view app.v_aml_same_beneficiary_burst as
select t.shop_id,
t.beneficiary_phone,
date_trunc('hour', t.occurred_at) as window_hour,
count(*) as cnt,
count(distinct t.user_id) as cashier_count,
sum(t.gross_usd) as sum_usd,
sum(t.gross_lbp) as sum_lbp
from app.transactions t
where t.status = 'completed'
and t.service_code in ('OMT_SEND','WU_SEND')
and t.beneficiary_phone is not null
group by 1,2,3
having count(*) >= 3 and count(distinct t.user_id) >= 2;
grant select on app.v_aml_same_beneficiary_burst to authenticated;
-- End migration 0013 ----------------------------------------------------
@@ -0,0 +1,105 @@
-- =====================================================================
-- Migration 0014 — Product Catalog for fixed-price commodities
--
-- Replaces manual cost/face value entry for standard recharges, goods,
-- and fixed-fee services with a controlled catalog managed by owners.
-- =====================================================================
create table if not exists app.products (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete cascade,
service_code text not null, -- e.g., 'ALFA_RECHARGE', 'TOUCH_RECHARGE'
product_code text not null, -- e.g., 'ALFA_10', 'TOUCH_22.73'
name citext not null, -- User-facing display name
unit_cost_usd numeric(12,2) not null default 0 constraint ck_cost_usd_nonneg check (unit_cost_usd >= 0),
unit_face_usd numeric(12,2) not null default 0 constraint ck_face_usd_nonneg check (unit_face_usd >= 0),
unit_cost_lbp numeric(16,0) not null default 0 constraint ck_cost_lbp_nonneg check (unit_cost_lbp >= 0),
unit_face_lbp numeric(16,0) not null default 0 constraint ck_face_lbp_nonneg check (unit_face_lbp >= 0),
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint unq_product_code_per_shop unique (shop_id, service_code, product_code)
);
create index if not exists idx_products_shop_service on app.products(shop_id, service_code) where is_active = true;
-- Update trigger
create or replace function app.trg_products_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
do $$ begin
create trigger trg_products_updated_at
before update on app.products
for each row execute function app.trg_products_updated_at();
exception when duplicate_object then null; end $$;
-- =====================================================================
-- RLS
-- =====================================================================
alter table app.products enable row level security;
alter table app.products force row level security;
-- Cashiers and managers can select active products
create policy "Staff can read active products"
on app.products for select
to authenticated
using (
app.has_any_role_in_shop(shop_id, array['owner', 'manager', 'cashier', 'auditor']::app.business_role[])
and (is_active = true or app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[]))
);
-- Owners and admins can manage products globally or per shop
create policy "Owners and managers can insert products"
on app.products for insert
to authenticated
with check (
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
);
create policy "Owners and managers can update products"
on app.products for update
to authenticated
using (
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
)
with check (
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
);
create policy "Owners and managers can delete products"
on app.products for delete
to authenticated
using (
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
);
-- =====================================================================
-- Seed with initial safety defaults
-- =====================================================================
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
select s.id, 'ALFA_RECHARGE', 'ALFA_10', 'Alfa $10', 10.00, 9.50
from app.shops s
on conflict on constraint unq_product_code_per_shop do nothing;
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
select s.id, 'ALFA_RECHARGE', 'ALFA_22.73', 'Alfa $22.73', 22.73, 22.50
from app.shops s
on conflict on constraint unq_product_code_per_shop do nothing;
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
select s.id, 'TOUCH_RECHARGE', 'TOUCH_10', 'Touch $10', 10.00, 9.50
from app.shops s
on conflict on constraint unq_product_code_per_shop do nothing;
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
select s.id, 'TOUCH_RECHARGE', 'TOUCH_22.73', 'Touch $22.73', 22.73, 22.50
from app.shops s
on conflict on constraint unq_product_code_per_shop do nothing;
+70
View File
@@ -0,0 +1,70 @@
-- =====================================================================
-- Migration 0015 — Mid-Day Shift Cash Drops
--
-- Enables cashiers to "drop" large sums of accumulated cash (esp USD payout cash)
-- into a safe midway through a shift, removing their liability without
-- requiring them to close out and open a brand new shift.
-- =====================================================================
create or replace function app.record_cash_drop(
p_shift_id uuid,
p_drop_usd numeric,
p_drop_lbp numeric,
p_notes text default null
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_till uuid;
v_status text;
v_user uuid;
begin
select shop_id, till_id, status, user_id
into v_shop, v_till, v_status, v_user
from app.shifts
where id = p_shift_id;
if v_shop is null then
raise exception 'Shift not found';
end if;
if v_user <> auth.uid() and not app.has_role_in_shop(v_shop, 'manager') then
raise exception 'Only the shift owner or a manager may record a drop';
end if;
if v_status <> 'open' then
raise exception 'Must have an open shift to record a soft drop';
end if;
if p_drop_usd < 0 or p_drop_lbp < 0 then
raise exception 'Drop amounts cannot be negative';
end if;
if p_drop_usd = 0 and p_drop_lbp = 0 then
raise exception 'Must drop > 0 in at least one currency';
end if;
-- Create a matching cash_movements record reducing the drawer balance
insert into app.cash_movements(
shift_id, movement_type, currency, amount, external_ref
)
select
p_shift_id,
'safe_drop',
case when d.idx = 1 then 'USD' else 'LBP' end,
case when d.idx = 1 then p_drop_usd else p_drop_lbp end,
p_notes
from (values (1), (2)) as d(idx)
where (d.idx = 1 and p_drop_usd > 0)
or (d.idx = 2 and p_drop_lbp > 0);
perform app.log_auth_event('safe_drop_recorded', v_shop, null,
jsonb_build_object('shift_id', p_shift_id, 'usd', p_drop_usd, 'lbp', p_drop_lbp));
end;
$$;
revoke all on function app.record_cash_drop(uuid, numeric, numeric, text) from public;
grant execute on function app.record_cash_drop(uuid, numeric, numeric, text) to authenticated;
@@ -0,0 +1,78 @@
-- =====================================================================
-- Migration 0016: Shift Assignments
-- Extends open_shift so managers/owners can assign a shift to any employee.
-- =====================================================================
drop function if exists app.open_shift(uuid, numeric, numeric);
create or replace function app.open_shift(
p_till_id uuid,
p_opening_usd numeric,
p_opening_lbp numeric,
p_assigned_user_id uuid default null
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_shift uuid;
v_target_user uuid;
begin
if p_opening_usd is null or p_opening_lbp is null then
raise exception 'opening counts are required';
end if;
if p_opening_usd < 0 or p_opening_lbp < 0 then
raise exception 'opening counts must be non-negative';
end if;
select shop_id into v_shop from app.tills where id = p_till_id and is_active;
if v_shop is null then
raise exception 'till % not found or inactive', p_till_id;
end if;
v_target_user := coalesce(p_assigned_user_id, auth.uid());
-- Caller must have a role to open.
if not app.has_any_role_in_shop(v_shop, array['owner','manager','cashier']::app.business_role[]) then
raise exception 'not authorized to open a shift on this till';
end if;
-- If trying to open for someone else, must be owner or manager
if v_target_user <> auth.uid() then
if not app.has_any_role_in_shop(v_shop, array['owner','manager']::app.business_role[]) then
raise exception 'only managers or owners can assign shifts to other users';
end if;
end if;
-- Target user must have a role in the shop
if not exists (
select 1 from app.user_shop_assignments
where user_id = v_target_user and shop_id = v_shop
) then
raise exception 'target user does not have a role in this shop';
end if;
-- Reject if any non-closed shift exists on this till.
if exists (select 1 from app.shifts where till_id = p_till_id and status <> 'closed') then
raise exception 'till % already has an active shift; close it first', p_till_id;
end if;
insert into app.shifts(till_id, shop_id, user_id, opened_by, opening_usd, opening_lbp)
values (p_till_id, v_shop, v_target_user, auth.uid(), p_opening_usd, p_opening_lbp)
returning id into v_shift;
-- Record the opening float as a cash movement for clean ledgers.
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (v_shift, 'opening_float', 'USD', p_opening_usd, 'opening float'),
(v_shift, 'opening_float', 'LBP', p_opening_lbp, 'opening float');
perform app.log_auth_event('shift_opened', v_shop, null,
jsonb_build_object('shift_id', v_shift, 'till_id', p_till_id, 'assigned_user_id', v_target_user));
return v_shift;
end;
$$;
revoke all on function app.open_shift(uuid, numeric, numeric, uuid) from public;
grant execute on function app.open_shift(uuid, numeric, numeric, uuid) to authenticated;
@@ -0,0 +1,20 @@
create or replace function app.get_shop_users(p_shop_id uuid)
returns table (
user_id uuid,
full_name text,
role text
) language sql
security definer
set search_path = app, public
as $$
select
usa.user_id,
up.full_name,
usa.role::text
from app.user_shop_assignments usa
join app.user_profiles up on up.user_id = usa.user_id
where usa.shop_id = p_shop_id;
$$;
revoke all on function app.get_shop_users(uuid) from public;
grant execute on function app.get_shop_users(uuid) to authenticated;
@@ -0,0 +1,506 @@
-- =====================================================================
-- Migration 0018 — Money-transfer cash + float coupling.
--
-- Closes the largest hole in the cash-control model: until now,
-- record_omt_send / record_omt_receive / record_bill only inserted into
-- app.transactions and the detail table. They did NOT post anything to
-- app.cash_movements or app.float_movements, so:
--
-- * v_z_report.expected_close_usd = sum(cash_movements) was wrong by
-- the entire transfer turnover, hiding cashier shortages.
-- * The OMT/Whish/WU/biller float balance never moved, so we couldn't
-- tell who owed whom and the matcher could only compare by
-- external_ref, never by money.
-- * Threat-model rows #1, #5, #21 had no DB-level enforcement for
-- money transfers (only recharges had the deferred-trigger
-- constraint).
--
-- This migration:
-- 1. Adds app._post_cash_for_txn / app._post_float_for_txn helpers.
-- 2. Adds app._get_or_create_float(shop, provider, currency).
-- 3. Re-defines record_omt_send / record_omt_receive / record_bill so
-- each posts the cash leg (when payment_method is cash_*) and the
-- provider-float leg in the same SECURITY DEFINER body.
-- 4. Adds record_whish_send, record_wu_send, record_wu_receive so the
-- UI does not silently file Whish/WU under provider='OMT'.
-- 5. Adds a deferred constraint trigger that requires every completed
-- money-transfer / bill txn to have at least one float_movement
-- row. Recharges already have their own coupling trigger from
-- 0005; goods sales have one too.
--
-- Threat-model rows: 1, 5, 7, 8, 14, 21, 24.
-- =====================================================================
-- =====================================================================
-- Helper: get_or_create the float account the txn should debit/credit.
-- =====================================================================
create or replace function app._get_or_create_float(
p_shop uuid,
p_provider app.float_provider,
p_currency app.currency_code
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_id uuid;
begin
select id into v_id from app.floats
where shop_id = p_shop and provider = p_provider and currency = p_currency;
if v_id is null then
insert into app.floats(shop_id, provider, currency)
values (p_shop, p_provider, p_currency)
returning id into v_id;
-- Initialise the cached balance row at zero.
insert into app.float_balances(float_id, balance) values (v_id, 0)
on conflict (float_id) do nothing;
end if;
return v_id;
end;
$$;
revoke all on function app._get_or_create_float(uuid, app.float_provider, app.currency_code)
from public;
-- =====================================================================
-- Helper: post the cash leg for a customer-facing transaction.
--
-- Convention (matches 0002 cash_movements):
-- * Positive amount = cash into till.
-- * Negative amount = cash out of till.
-- This function takes a "customer movement" sign:
-- * p_customer_paid > 0 -> cash_in (sale_in) amount = +p_customer_paid
-- * p_customer_paid < 0 -> cash_out (payout_out) amount = p_customer_paid
-- For non-cash payment methods (whish, omt_wallet, card, bank_transfer)
-- the cash leg is skipped — those balances live on their own floats.
-- =====================================================================
create or replace function app._post_cash_for_txn(
p_txn_id uuid,
p_payment_method app.payment_method,
p_customer_usd numeric,
p_customer_lbp numeric
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shift uuid;
v_type app.cash_movement_type;
begin
-- Only cash-in-till payment methods produce a cash leg.
if p_payment_method not in ('cash_usd','cash_lbp') then
return;
end if;
select shift_id into v_shift from app.transactions where id = p_txn_id;
if v_shift is null then
raise exception 'txn % not found while posting cash leg', p_txn_id;
end if;
-- For cash_usd payment method, only USD leg may move; same for LBP.
if p_payment_method = 'cash_usd' then
if coalesce(p_customer_lbp, 0) <> 0 then
raise exception 'cash_usd payment must not move LBP (got %)', p_customer_lbp;
end if;
if coalesce(p_customer_usd, 0) = 0 then return; end if;
v_type := case when p_customer_usd > 0 then 'sale_in'::app.cash_movement_type
else 'payout_out'::app.cash_movement_type end;
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (v_shift, v_type, 'USD', p_customer_usd, p_txn_id, 'auto: txn cash leg');
else -- cash_lbp
if coalesce(p_customer_usd, 0) <> 0 then
raise exception 'cash_lbp payment must not move USD (got %)', p_customer_usd;
end if;
if coalesce(p_customer_lbp, 0) = 0 then return; end if;
v_type := case when p_customer_lbp > 0 then 'sale_in'::app.cash_movement_type
else 'payout_out'::app.cash_movement_type end;
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (v_shift, v_type, 'LBP', p_customer_lbp, p_txn_id, 'auto: txn cash leg');
end if;
end;
$$;
revoke all on function app._post_cash_for_txn(uuid, app.payment_method, numeric, numeric)
from public;
-- =====================================================================
-- Helper: post the provider-float leg for a customer-facing transaction.
--
-- p_amount sign convention on app.float_movements:
-- + : float increases (provider owes shop more, e-recharge wallet
-- topped up, OMT credits us at settlement, ...)
-- - : float decreases (we used it up, we owe provider more cash, ...)
-- =====================================================================
create or replace function app._post_float_for_txn(
p_txn_id uuid,
p_provider app.float_provider,
p_currency app.currency_code,
p_amount numeric,
p_reason text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid; v_shift uuid; v_float uuid;
begin
if coalesce(p_amount, 0) = 0 then return; end if;
select shop_id, shift_id into v_shop, v_shift
from app.transactions where id = p_txn_id;
if v_shop is null then
raise exception 'txn % not found while posting float leg', p_txn_id;
end if;
v_float := app._get_or_create_float(v_shop, p_provider, p_currency);
insert into app.float_movements(float_id, shift_id, amount, ref_txn_id, reason)
values (v_float, v_shift, p_amount, p_txn_id, p_reason);
end;
$$;
revoke all on function app._post_float_for_txn(uuid, app.float_provider,
app.currency_code, numeric, text) from public;
-- =====================================================================
-- Map a money-transfer service code to its float provider.
-- =====================================================================
create or replace function app._money_transfer_provider(p_service text)
returns app.float_provider
language sql
immutable
as $$
select case p_service
when 'OMT_SEND' then 'OMT_CASH'::app.float_provider
when 'OMT_RECEIVE' then 'OMT_CASH'::app.float_provider
when 'OMT_BILL' then 'OMT_CASH'::app.float_provider
when 'WU_SEND' then 'OMT_CASH'::app.float_provider -- WU runs on the OMT cash pool in LB
when 'WU_RECEIVE' then 'OMT_CASH'::app.float_provider
when 'WHISH_SEND' then 'WHISH'::app.float_provider
when 'EDL_BILL' then 'OMT_CASH'::app.float_provider -- EDL paid via OMT counter
end;
$$;
-- =====================================================================
-- Re-define record_omt_send to post cash + float in one go.
-- Provider is stamped from the service code, not hard-coded.
-- =====================================================================
create or replace function app.record_omt_send(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_direction app.transfer_direction,
p_sender_full_name text, p_sender_id_type app.id_doc_type,
p_sender_id_number text, p_sender_phone text,
p_sender_dob date, p_sender_nationality text,
p_beneficiary_full_name text, p_beneficiary_phone text,
p_destination_country text,
p_purpose_code text, p_purpose_note text,
p_kyc_doc_url text,
p_customer_id uuid,
p_notes text,
p_service_code text default 'OMT_SEND' -- 'OMT_SEND' | 'WU_SEND' | 'WHISH_SEND'
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
v_provider_lbl text;
v_float_prov app.float_provider;
begin
if p_service_code not in ('OMT_SEND','WU_SEND','WHISH_SEND') then
raise exception 'record_omt_send: unsupported service %', p_service_code;
end if;
v_provider_lbl := case p_service_code
when 'OMT_SEND' then 'OMT'
when 'WU_SEND' then 'WU'
when 'WHISH_SEND' then 'WHISH'
end;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
v_provider_lbl, p_external_ref,
p_beneficiary_full_name, p_beneficiary_phone,
p_customer_id, p_notes);
insert into app.omt_send_details(
txn_id, direction,
sender_full_name, sender_id_type, sender_id_number, sender_phone,
sender_dob, sender_nationality,
beneficiary_full_name, beneficiary_phone, destination_country,
purpose_code, purpose_note, kyc_doc_url
) values (
v_txn, p_direction,
p_sender_full_name, p_sender_id_type, p_sender_id_number, p_sender_phone,
p_sender_dob, p_sender_nationality,
p_beneficiary_full_name, p_beneficiary_phone, p_destination_country,
p_purpose_code, p_purpose_note, p_kyc_doc_url
);
-- Cash leg: customer hands over gross + fee in cash.
perform app._post_cash_for_txn(v_txn, p_payment_method,
coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0));
-- Float leg: shop now owes the provider gross (we keep fee+comm).
v_float_prov := app._money_transfer_provider(p_service_code);
perform app._post_float_for_txn(v_txn, v_float_prov, 'USD',
-coalesce(p_gross_usd,0), 'send: shop owes provider gross');
perform app._post_float_for_txn(v_txn, v_float_prov, 'LBP',
-coalesce(p_gross_lbp,0), 'send: shop owes provider gross');
return v_txn;
end; $$;
revoke all on function app.record_omt_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, date, text,
text, text, text, text, text, text, uuid, text, text) from public;
grant execute on function app.record_omt_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, date, text,
text, text, text, text, text, text, uuid, text, text) to authenticated;
-- =====================================================================
-- Re-define record_omt_receive (also serves WU_RECEIVE).
-- Customer presents code, cashier hands them gross.
-- =====================================================================
create or replace function app.record_omt_receive(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_payout_code text,
p_beneficiary_full_name text,
p_beneficiary_id_type app.id_doc_type,
p_beneficiary_id_number text,
p_beneficiary_phone text,
p_origin_country text,
p_kyc_doc_url text,
p_customer_id uuid,
p_notes text,
p_service_code text default 'OMT_RECEIVE' -- or 'WU_RECEIVE'
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
v_provider_lbl text;
v_float_prov app.float_provider;
v_net_usd numeric;
v_net_lbp numeric;
begin
if p_service_code not in ('OMT_RECEIVE','WU_RECEIVE') then
raise exception 'record_omt_receive: unsupported service %', p_service_code;
end if;
v_provider_lbl := case p_service_code
when 'OMT_RECEIVE' then 'OMT'
when 'WU_RECEIVE' then 'WU'
end;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
v_provider_lbl, p_payout_code,
p_beneficiary_full_name, p_beneficiary_phone,
p_customer_id, p_notes);
insert into app.omt_receive_details(
txn_id, payout_code,
beneficiary_full_name, beneficiary_id_type, beneficiary_id_number,
beneficiary_phone, origin_country, kyc_doc_url
) values (
v_txn, p_payout_code,
p_beneficiary_full_name, p_beneficiary_id_type, p_beneficiary_id_number,
p_beneficiary_phone, p_origin_country, p_kyc_doc_url
);
-- Cash leg: shop pays gross out, may collect a small fee from beneficiary.
-- net cash to till = -gross + fee
-- (Most LB payouts have no beneficiary-side fee; if fee=0 this just
-- becomes -gross.)
v_net_usd := -coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0);
v_net_lbp := -coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0);
perform app._post_cash_for_txn(v_txn, p_payment_method, v_net_usd, v_net_lbp);
-- Float leg: provider now owes the shop gross + commission.
v_float_prov := app._money_transfer_provider(p_service_code);
perform app._post_float_for_txn(v_txn, v_float_prov, 'USD',
coalesce(p_gross_usd,0) + coalesce(p_commission_usd,0),
'receive: provider owes shop gross + commission');
perform app._post_float_for_txn(v_txn, v_float_prov, 'LBP',
coalesce(p_gross_lbp,0) + coalesce(p_commission_lbp,0),
'receive: provider owes shop gross + commission');
return v_txn;
end; $$;
revoke all on function app.record_omt_receive(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, text, app.id_doc_type, text, text, text, text, uuid, text, text) from public;
grant execute on function app.record_omt_receive(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, text, app.id_doc_type, text, text, text, text, uuid, text, text) to authenticated;
-- =====================================================================
-- Re-define record_bill (OMT_BILL / EDL_BILL) with cash + float legs.
-- =====================================================================
create or replace function app.record_bill(
p_shop uuid, p_till uuid, p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_biller_code text, p_account_number text,
p_period text, p_customer_name text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
v_float_prov app.float_provider;
begin
if p_service_code not in ('OMT_BILL','EDL_BILL') then
raise exception 'record_bill only for bill services, got %', p_service_code;
end if;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
p_biller_code, p_external_ref,
p_customer_name, null,
p_customer_id, p_notes);
insert into app.bill_payment_details(
txn_id, biller_code, account_number, period, customer_name
) values (
v_txn, p_biller_code, p_account_number, p_period, p_customer_name
);
-- Cash leg: customer pays gross + fee.
perform app._post_cash_for_txn(v_txn, p_payment_method,
coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0));
-- Float leg: shop now owes the biller's settlement counterparty
-- gross. EDL/OMT_BILL settle through the OMT cash pool in our model.
v_float_prov := app._money_transfer_provider(p_service_code);
if v_float_prov is not null then
perform app._post_float_for_txn(v_txn, v_float_prov, 'USD',
-coalesce(p_gross_usd,0), 'bill: shop owes biller gross');
perform app._post_float_for_txn(v_txn, v_float_prov, 'LBP',
-coalesce(p_gross_lbp,0), 'bill: shop owes biller gross');
end if;
return v_txn;
end; $$;
revoke all on function app.record_bill(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text)
from public;
grant execute on function app.record_bill(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text)
to authenticated;
-- =====================================================================
-- Convenience wrapper for Whish — same shape as omt_send_details for
-- now (sender + beneficiary). The UI sends WHISH_SEND and gets a
-- correctly tagged provider on the txn row.
-- =====================================================================
create or replace function app.record_whish_send(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_commission_usd numeric, p_commission_lbp numeric,
p_fx_rate numeric,
p_external_ref text,
p_direction app.transfer_direction,
p_sender_full_name text, p_sender_id_type app.id_doc_type,
p_sender_id_number text, p_sender_phone text,
p_beneficiary_full_name text, p_beneficiary_phone text,
p_purpose_code text, p_purpose_note text,
p_kyc_doc_url text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
begin
return app.record_omt_send(
p_shop, p_till, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
p_commission_usd, p_commission_lbp, p_fx_rate,
p_external_ref, p_direction,
p_sender_full_name, p_sender_id_type, p_sender_id_number, p_sender_phone,
null, null,
p_beneficiary_full_name, p_beneficiary_phone, null,
p_purpose_code, p_purpose_note, p_kyc_doc_url,
p_customer_id, p_notes,
'WHISH_SEND'
);
end; $$;
revoke all on function app.record_whish_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, text, text, text, text, text, uuid, text)
from public;
grant execute on function app.record_whish_send(uuid, uuid, app.payment_method,
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
text, app.transfer_direction,
text, app.id_doc_type, text, text, text, text, text, text, text, uuid, text)
to authenticated;
-- =====================================================================
-- Deferred constraint trigger: every completed money-transfer / bill
-- transaction must end up with at least one float_movement row. The
-- trigger fires at COMMIT, so the record_* functions above can post the
-- float leg after the txn insert in the same transaction.
-- =====================================================================
create or replace function app._money_transfer_require_movement()
returns trigger
language plpgsql
as $$
declare
ok boolean;
is_money_transfer boolean;
begin
if new.status <> 'completed' then return null; end if;
is_money_transfer := new.service_code in
('OMT_SEND','OMT_RECEIVE','OMT_BILL','EDL_BILL',
'WU_SEND','WU_RECEIVE','WHISH_SEND');
if not is_money_transfer then return null; end if;
-- If both gross sides are 0, no money moved -> nothing to require.
if coalesce(new.gross_usd,0) = 0 and coalesce(new.gross_lbp,0) = 0 then
return null;
end if;
select exists (
select 1 from app.float_movements
where ref_txn_id = new.id
) into ok;
if not ok then
raise exception 'money-transfer txn % (service %) has no float_movement leg',
new.id, new.service_code;
end if;
return null;
end;
$$;
drop trigger if exists trg_money_transfer_require_movement on app.transactions;
create constraint trigger trg_money_transfer_require_movement
after insert on app.transactions
deferrable initially deferred
for each row execute function app._money_transfer_require_movement();
-- End migration 0018 ----------------------------------------------------
@@ -0,0 +1,121 @@
-- =====================================================================
-- Migration 0019 — Cash-movement sign guard + fix record_cash_drop.
--
-- Two bugs in 0015 + 0002:
--
-- (a) record_cash_drop in 0015 inserted into app.cash_movements using
-- column names that do not exist (`movement_type`, `external_ref`)
-- and an enum value that doesn't exist (`safe_drop`). The real
-- schema is `type` / `note` and the enum value is `drop_to_safe`.
-- Worse, it inserted the drop amount as POSITIVE, which would make
-- `expected_close_usd` go UP when cash physically left the till.
--
-- (b) app.cash_movements has no constraint that the sign of `amount`
-- matches the movement `type`. A buggy or malicious insert with
-- type='drop_to_safe' amount=+1000 would silently increase the
-- expected drawer balance.
--
-- This migration:
-- 1. Adds a BEFORE INSERT trigger on app.cash_movements enforcing the
-- sign-vs-type rule.
-- 2. Replaces app.record_cash_drop with a correct implementation
-- using the real columns and a negative sign.
--
-- Threat-model rows: 7, 14, 22, 25.
-- =====================================================================
-- =====================================================================
-- Sign-vs-type guard
-- =====================================================================
create or replace function app._cash_mov_sign_check()
returns trigger
language plpgsql
as $$
begin
-- + cash into till
if new.type in ('opening_float','sale_in','fx_swap_in') then
if new.amount <= 0 then
raise exception 'cash_movements.type=% must have positive amount (got %)',
new.type, new.amount;
end if;
-- - cash out of till
elsif new.type in ('payout_out','drop_to_safe','bank_deposit',
'expense','fx_swap_out') then
if new.amount >= 0 then
raise exception 'cash_movements.type=% must have negative amount (got %)',
new.type, new.amount;
end if;
-- 'adjustment' is the only type that may legitimately go either way
-- (manager-approved correction). It must still be non-zero (already
-- enforced by the table CHECK).
end if;
return new;
end;
$$;
drop trigger if exists trg_cash_mov_sign_check on app.cash_movements;
create trigger trg_cash_mov_sign_check
before insert on app.cash_movements
for each row execute function app._cash_mov_sign_check();
-- =====================================================================
-- Replace record_cash_drop with a correct implementation.
-- Drops are negative cash_movement rows of type 'drop_to_safe'.
-- =====================================================================
create or replace function app.record_cash_drop(
p_shift_id uuid,
p_drop_usd numeric,
p_drop_lbp numeric,
p_notes text default null
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shop uuid;
v_status app.shift_status;
v_user uuid;
begin
if p_drop_usd is null or p_drop_lbp is null
or p_drop_usd < 0 or p_drop_lbp < 0 then
raise exception 'drop amounts must be non-negative numbers';
end if;
if coalesce(p_drop_usd,0) = 0 and coalesce(p_drop_lbp,0) = 0 then
raise exception 'must drop > 0 in at least one currency';
end if;
select shop_id, status, user_id
into v_shop, v_status, v_user
from app.shifts
where id = p_shift_id;
if v_shop is null then
raise exception 'shift % not found', p_shift_id;
end if;
if v_status <> 'open' then
raise exception 'shift must be open to record a drop (got %)', v_status;
end if;
if v_user <> auth.uid() and not app.has_role_in_shop(v_shop, 'manager') then
raise exception 'only the shift owner or a manager may record a drop';
end if;
if p_drop_usd > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (p_shift_id, 'drop_to_safe', 'USD', -p_drop_usd,
coalesce(p_notes, 'mid-day safe drop'));
end if;
if p_drop_lbp > 0 then
insert into app.cash_movements(shift_id, type, currency, amount, note)
values (p_shift_id, 'drop_to_safe', 'LBP', -p_drop_lbp,
coalesce(p_notes, 'mid-day safe drop'));
end if;
perform app.log_auth_event('safe_drop_recorded', v_shop, null,
jsonb_build_object('shift_id', p_shift_id,
'usd', p_drop_usd, 'lbp', p_drop_lbp));
end;
$$;
revoke all on function app.record_cash_drop(uuid, numeric, numeric, text) from public;
grant execute on function app.record_cash_drop(uuid, numeric, numeric, text) to authenticated;
-- End migration 0019 ----------------------------------------------------
@@ -0,0 +1,233 @@
-- =====================================================================
-- 0020_void_reverses_movements.sql
--
-- Up to and including 0019, app.void_transaction() only flipped the
-- transactions.status flag to 'voided'. The original cash_movements,
-- float_movements, stock_movements and voucher_inventory rows that the
-- record_* functions had posted stayed in place, so the till expected
-- balance, OMT/Whish float balance and stock-on-hand were never
-- corrected. A cashier could record a $500 OMT_SEND, pocket the $500,
-- then void the txn five minutes later and the books would still show
-- $500 received in the till.
--
-- This migration makes void a true accounting reversal:
-- * for every cash_movements row tied to the txn we post an opposite-
-- signed `adjustment` row (sign guard from 0019 allows either sign
-- for adjustment),
-- * for every float_movements row we post an opposite-signed row,
-- * for every stock_movements row we post an opposite type
-- (sale_out -> adjustment_in, return_in -> adjustment_out, etc.)
-- with the void approver as approved_by,
-- * any voucher_inventory marked sold by the txn is returned to
-- `in_stock` so the serial can be re-sold.
--
-- The reversals reference the same ref_txn_id so reconciliation views
-- and the receipts trail keep them paired with the original posting.
-- =====================================================================
set search_path = app, public;
-- ---------------------------------------------------------------------
-- Helper: post compensating cash + float + stock + voucher rows for a
-- transaction that is being voided. Returns nothing; the caller is
-- responsible for flipping the txn status itself.
-- ---------------------------------------------------------------------
create or replace function app._reverse_movements_for_txn(
p_txn_id uuid,
p_approver uuid,
p_reason text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
r_cash record;
r_flt record;
r_stk record;
v_reverse_type app.stock_movement_type;
v_note text;
begin
select * into t from app.transactions where id = p_txn_id;
if t.id is null then
raise exception 'reverse: txn % not found', p_txn_id;
end if;
v_note := 'void reversal: ' || coalesce(p_reason, '');
-- ----- Cash legs --------------------------------------------------
-- Re-post each existing cash_movements row with opposite sign as an
-- 'adjustment' (the only cash_movement_type that the 0019 sign guard
-- lets carry either sign).
for r_cash in
select id, shift_id, currency, amount
from app.cash_movements
where ref_txn_id = p_txn_id
and type <> 'adjustment' -- don't reverse prior reversals
loop
insert into app.cash_movements(
shift_id, type, currency, amount, ref_txn_id, note, created_by
) values (
r_cash.shift_id,
'adjustment'::app.cash_movement_type,
r_cash.currency,
-r_cash.amount,
p_txn_id,
v_note,
p_approver
);
end loop;
-- ----- Float legs -------------------------------------------------
for r_flt in
select id, float_id, shift_id, amount
from app.float_movements
where ref_txn_id = p_txn_id
and coalesce(reason,'') not like 'void reversal%'
loop
insert into app.float_movements(
float_id, shift_id, amount, ref_txn_id, reason, created_by
) values (
r_flt.float_id,
r_flt.shift_id,
-r_flt.amount,
p_txn_id,
v_note,
p_approver
);
end loop;
-- ----- Stock legs -------------------------------------------------
-- For physical goods sold via record_goods_sale, reverse the
-- sale_out by posting an adjustment_in of equal magnitude (positive),
-- and vice versa for any in-bound rows tied to this txn.
for r_stk in
select id, sku, shop_id, shift_id, type, qty_delta, ref_lot_id
from app.stock_movements
where ref_txn_id = p_txn_id
and type not in ('adjustment_in','adjustment_out')
loop
if r_stk.qty_delta < 0 then
v_reverse_type := 'adjustment_in'::app.stock_movement_type;
else
v_reverse_type := 'adjustment_out'::app.stock_movement_type;
end if;
insert into app.stock_movements(
sku, shop_id, shift_id, type, qty_delta,
ref_txn_id, ref_lot_id, approved_by, reason, created_by
) values (
r_stk.sku, r_stk.shop_id, r_stk.shift_id,
v_reverse_type,
-r_stk.qty_delta,
p_txn_id, r_stk.ref_lot_id,
p_approver,
v_note,
p_approver
);
end loop;
-- ----- Voucher serials -------------------------------------------
-- Any voucher marked sold by this txn returns to in_stock so it can
-- be sold again. (The voucher_status_consistency CHECK clears
-- sold_txn_id / sold_at when status becomes 'in_stock'.)
update app.voucher_inventory
set status = 'in_stock',
sold_txn_id = null,
sold_at = null,
status_changed_by = p_approver,
status_change_reason = v_note
where sold_txn_id = p_txn_id
and status = 'sold';
end;
$$;
revoke all on function app._reverse_movements_for_txn(uuid, uuid, text) from public;
-- Internal helper only — callable from void_transaction (security definer).
-- ---------------------------------------------------------------------
-- Re-define void_transaction to reverse movements before flipping
-- status. We keep the same signature as 0003 so the existing UI calls
-- continue to work.
-- ---------------------------------------------------------------------
create or replace function app.void_transaction(
p_txn_id uuid,
p_reason text,
p_approver_pin text default null
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
s app.shifts%rowtype;
window_min int;
needs_manager boolean;
v_approver uuid;
begin
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'transaction not found'; end if;
if t.status = 'voided' then raise exception 'transaction already voided'; end if;
if p_reason is null or length(btrim(p_reason)) < 5 then
raise exception 'a reason of at least 5 characters is required';
end if;
select * into s from app.shifts where id = t.shift_id;
if s.status <> 'open' then
raise exception 'cannot void a transaction whose shift is no longer open';
end if;
select coalesce(value::int, 10) into window_min
from app.system_settings where key = 'void_self_window_minutes';
needs_manager := (auth.uid() <> t.user_id)
or (now() - t.created_at > make_interval(mins => window_min));
if needs_manager then
if not app.has_role_in_shop(t.shop_id, 'manager') then
raise exception 'manager approval required to void this transaction';
end if;
if p_approver_pin is null or not app.verify_my_pin(p_approver_pin) then
raise exception 'manager PIN required and must be valid';
end if;
end if;
v_approver := auth.uid();
-- Reverse the money / stock / voucher legs FIRST. If any of these
-- inserts fails (e.g. stock would go negative because more vouchers
-- have been sold from the lot since), the whole void is rolled back
-- and the books stay consistent.
if t.status = 'completed' then
perform app._reverse_movements_for_txn(p_txn_id, v_approver, p_reason);
end if;
-- Flip the status (only this function may UPDATE app.transactions).
perform set_config('app.txn_internal', 'on', true);
update app.transactions
set status = 'voided',
voided_at = now(),
voided_by = v_approver,
void_reason = p_reason,
void_approved_by = case when needs_manager then v_approver else null end
where id = p_txn_id;
perform set_config('app.txn_internal', 'off', true);
-- Recompute the row's hash so the chain reflects the new state.
perform set_config('app.txn_internal', 'on', true);
update app.transactions tt
set row_hash = app.txn_compute_hash(tt, tt.prev_row_hash)
where id = p_txn_id;
perform set_config('app.txn_internal', 'off', true);
perform app.log_auth_event('txn_voided', t.shop_id, null,
jsonb_build_object('txn_id', p_txn_id,
'manager_path', needs_manager,
'reversed', t.status = 'completed'));
end;
$$;
revoke all on function app.void_transaction(uuid, text, text) from public;
grant execute on function app.void_transaction(uuid, text, text) to authenticated;
+230
View File
@@ -0,0 +1,230 @@
-- =====================================================================
-- 0021_fee_schedule.sql
--
-- Today the cashier types fee_usd / fee_lbp / commission_usd / commission_lbp
-- by hand on every OMT_SEND, OMT_RECEIVE, WU_*, WHISH_SEND, EDL_BILL,
-- recharge and goods sale. There is no server-side anchor for what the
-- fee is *supposed* to be, which means a cashier can:
--
-- * pocket part of the customer's fee by recording a smaller fee
-- than they collected,
-- * record a larger fee than the official sheet to siphon shop
-- commission, then refund the excess to themselves later.
--
-- This migration adds an opt-in per-shop fee schedule:
--
-- app.fee_schedule(shop_id, service_code, currency,
-- min_amount, max_amount,
-- fee_fixed, fee_pct,
-- commission_fixed, commission_pct,
-- tolerance)
--
-- and a deferred constraint trigger that, *only when at least one row
-- exists for the shop+service+currency*, validates the fee/commission
-- on the transaction against the bracket the gross falls into. Shops
-- that don't seed the table keep working exactly as before.
-- =====================================================================
set search_path = app, public;
create table if not exists app.fee_schedule (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete cascade,
service_code text not null references app.services(code),
currency app.currency_code not null,
-- Inclusive lower bound, exclusive upper bound (use a very large
-- max_amount for the "and above" bracket).
min_amount numeric(18,2) not null check (min_amount >= 0),
max_amount numeric(18,2) not null,
fee_fixed numeric(18,2) not null default 0 check (fee_fixed >= 0),
fee_pct numeric(7,4) not null default 0 check (fee_pct >= 0 and fee_pct <= 100),
commission_fixed numeric(18,2) not null default 0 check (commission_fixed >= 0),
commission_pct numeric(7,4) not null default 0 check (commission_pct >= 0 and commission_pct <= 100),
-- Allowed absolute tolerance between scheduled and recorded fee. Set
-- non-zero for services priced in LBP rounded to nearest 1000.
tolerance numeric(18,2) not null default 0 check (tolerance >= 0),
effective_from timestamptz not null default now(),
effective_to timestamptz,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid(),
check (max_amount > min_amount),
check (effective_to is null or effective_to > effective_from)
);
create index if not exists idx_fee_schedule_lookup
on app.fee_schedule(shop_id, service_code, currency, effective_from desc);
alter table app.fee_schedule enable row level security;
alter table app.fee_schedule force row level security;
-- Owners/managers of the shop can read and edit. Cashiers can read.
drop policy if exists fee_schedule_select on app.fee_schedule;
create policy fee_schedule_select on app.fee_schedule
for select using (
app.has_any_role_in_shop(shop_id,
array['cashier','manager','owner']::app.business_role[])
);
drop policy if exists fee_schedule_write on app.fee_schedule;
create policy fee_schedule_write on app.fee_schedule
for all using (
app.has_any_role_in_shop(shop_id,
array['manager','owner']::app.business_role[])
) with check (
app.has_any_role_in_shop(shop_id,
array['manager','owner']::app.business_role[])
);
-- ---------------------------------------------------------------------
-- Append-only on history: once published, a row's bracket cannot be
-- mutated; managers must close it (set effective_to) and insert a new
-- one. This preserves a clean audit trail of what fees were in force.
-- ---------------------------------------------------------------------
create or replace function app._fee_schedule_immutable()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'fee_schedule rows are append-only; close them with effective_to';
end if;
-- Only effective_to may move forward (close a bracket). Everything
-- else must stay put.
if (old.shop_id, old.service_code, old.currency, old.min_amount,
old.max_amount, old.fee_fixed, old.fee_pct,
old.commission_fixed, old.commission_pct, old.tolerance,
old.effective_from)
is distinct from
(new.shop_id, new.service_code, new.currency, new.min_amount,
new.max_amount, new.fee_fixed, new.fee_pct,
new.commission_fixed, new.commission_pct, new.tolerance,
new.effective_from)
then
raise exception 'fee_schedule columns are immutable; close the row and insert a new one';
end if;
if old.effective_to is not null then
raise exception 'fee_schedule row already closed';
end if;
if new.effective_to is null or new.effective_to <= now() - interval '1 minute' then
raise exception 'effective_to must be set to a current/future timestamp to close a bracket';
end if;
return new;
end;
$$;
drop trigger if exists trg_fee_schedule_immutable on app.fee_schedule;
create trigger trg_fee_schedule_immutable
before update or delete on app.fee_schedule
for each row execute function app._fee_schedule_immutable();
-- ---------------------------------------------------------------------
-- Lookup helper: returns the active bracket for a (shop, service,
-- currency, gross). Returns NULL if no schedule applies.
-- ---------------------------------------------------------------------
create or replace function app.compute_scheduled_fee(
p_shop uuid,
p_service text,
p_currency app.currency_code,
p_gross numeric
) returns table (
expected_fee numeric,
expected_commission numeric,
tolerance numeric,
bracket_id uuid
)
language sql
stable
security definer
set search_path = app, public
as $$
select
coalesce(fs.fee_fixed,0) + coalesce(fs.fee_pct,0) / 100.0 * p_gross,
coalesce(fs.commission_fixed,0) + coalesce(fs.commission_pct,0) / 100.0 * p_gross,
fs.tolerance,
fs.id
from app.fee_schedule fs
where fs.shop_id = p_shop
and fs.service_code = p_service
and fs.currency = p_currency
and fs.min_amount <= p_gross
and fs.max_amount > p_gross
and fs.effective_from <= now()
and (fs.effective_to is null or fs.effective_to > now())
order by fs.effective_from desc
limit 1;
$$;
revoke all on function app.compute_scheduled_fee(uuid, text, app.currency_code, numeric) from public;
grant execute on function app.compute_scheduled_fee(uuid, text, app.currency_code, numeric) to authenticated;
-- ---------------------------------------------------------------------
-- Constraint trigger: validates fee/commission against the schedule
-- when a matching bracket exists. Runs on INSERT (transactions are
-- append-only). Fired DEFERRED so the txn row is fully populated before
-- we look it up.
-- ---------------------------------------------------------------------
create or replace function app._fee_schedule_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
rec_usd record;
rec_lbp record;
diff numeric;
begin
-- Only validate completed money transactions; refunds, voids and
-- non-monetary services bypass.
if new.status <> 'completed' then return null; end if;
if new.service_code in ('REFUND','OPENING_FLOAT','SAFE_DROP','BANK_DEPOSIT')
then return null; end if;
if coalesce(new.gross_usd, 0) > 0 then
select * into rec_usd
from app.compute_scheduled_fee(new.shop_id, new.service_code, 'USD'::app.currency_code, new.gross_usd);
if rec_usd.bracket_id is not null then
diff := abs(coalesce(new.fee_usd,0) - rec_usd.expected_fee);
if diff > rec_usd.tolerance then
raise exception
'fee_usd % deviates from schedule % (tolerance %, bracket %)',
new.fee_usd, rec_usd.expected_fee, rec_usd.tolerance, rec_usd.bracket_id;
end if;
diff := abs(coalesce(new.commission_usd,0) - rec_usd.expected_commission);
if diff > rec_usd.tolerance then
raise exception
'commission_usd % deviates from schedule % (tolerance %, bracket %)',
new.commission_usd, rec_usd.expected_commission, rec_usd.tolerance, rec_usd.bracket_id;
end if;
end if;
end if;
if coalesce(new.gross_lbp, 0) > 0 then
select * into rec_lbp
from app.compute_scheduled_fee(new.shop_id, new.service_code, 'LBP'::app.currency_code, new.gross_lbp);
if rec_lbp.bracket_id is not null then
diff := abs(coalesce(new.fee_lbp,0) - rec_lbp.expected_fee);
if diff > rec_lbp.tolerance then
raise exception
'fee_lbp % deviates from schedule % (tolerance %, bracket %)',
new.fee_lbp, rec_lbp.expected_fee, rec_lbp.tolerance, rec_lbp.bracket_id;
end if;
diff := abs(coalesce(new.commission_lbp,0) - rec_lbp.expected_commission);
if diff > rec_lbp.tolerance then
raise exception
'commission_lbp % deviates from schedule % (tolerance %, bracket %)',
new.commission_lbp, rec_lbp.expected_commission, rec_lbp.tolerance, rec_lbp.bracket_id;
end if;
end if;
end if;
return null;
end;
$$;
drop trigger if exists trg_fee_schedule_check on app.transactions;
create constraint trigger trg_fee_schedule_check
after insert on app.transactions
deferrable initially deferred
for each row execute function app._fee_schedule_check();
@@ -0,0 +1,318 @@
-- =====================================================================
-- 0022_atomic_sale_coupling.sql
--
-- Today the cashier UI calls record_recharge() and record_goods_sale()
-- but never separately calls sell_voucher() or posts the e-float debit
-- / sale_out stock movement. The deferred constraint triggers from
-- 0005 (_recharge_require_movement, _goods_sale_require_movement)
-- therefore reject every commit at end-of-transaction… *unless* the
-- trigger never fires because the RLS-protected detail row blocked
-- the INSERT, in which case the txn header silently survives without
-- any inventory or float impact.
--
-- Either way the books are wrong: a "sold" voucher serial keeps
-- showing as in_stock, the e-float balance does not drop, and a phone
-- sold off the shelf does not decrement stock_on_hand.
--
-- This migration folds the inventory/float legs INTO the record_*
-- functions themselves, in the same SECURITY DEFINER transaction:
--
-- record_recharge -> if voucher_serial: mark voucher sold + post
-- sale_out (-1) for the voucher SKU.
-- else (e-recharge): post a negative float_movement
-- for ALFA_ERECHARGE / TOUCH_ERECHARGE / OGERO_ERECHARGE
-- sized at unit_cost_usd (or gross_usd as fallback).
--
-- record_goods_sale -> post a sale_out stock_movement for the SKU
-- with -p_qty.
--
-- Both are wrapped in a single transaction so either everything posts
-- or the whole sale rolls back. The deferred coupling triggers from
-- 0005 then pass naturally.
-- =====================================================================
set search_path = app, public;
-- ---------------------------------------------------------------------
-- Internal helper: mark a voucher sold + post sale_out, callable from
-- inside record_recharge. Mirrors app.sell_voucher() but does not check
-- auth.uid() against the txn owner because record_recharge is itself
-- security definer running as the cashier who created the txn.
-- ---------------------------------------------------------------------
create or replace function app._sell_voucher_internal(
p_txn_id uuid,
p_serial text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v app.voucher_inventory%rowtype;
t app.transactions%rowtype;
begin
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'txn not found'; end if;
select * into v from app.voucher_inventory
where serial = p_serial for update;
if v.serial is null then
raise exception 'voucher % not found', p_serial;
end if;
if v.shop_id <> t.shop_id then
raise exception 'voucher % belongs to a different shop', p_serial;
end if;
if v.status <> 'in_stock' then
raise exception 'voucher % is not in_stock (status=%)', p_serial, v.status;
end if;
update app.voucher_inventory
set status = 'sold',
sold_txn_id = p_txn_id,
sold_at = now(),
status_changed_by = auth.uid()
where serial = p_serial;
insert into app.stock_movements(
sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason
) values (
v.sku, v.shop_id, t.shift_id,
'sale_out'::app.stock_movement_type,
-1, p_txn_id, 'voucher ' || p_serial
);
end;
$$;
revoke all on function app._sell_voucher_internal(uuid, text) from public;
-- ---------------------------------------------------------------------
-- Internal helper: post the e-float debit for an e-recharge.
-- ---------------------------------------------------------------------
create or replace function app._erecharge_post_float(
p_txn_id uuid,
p_operator text,
p_amount numeric -- positive cost; the row will be negated
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_provider app.float_provider;
v_float uuid;
t app.transactions%rowtype;
begin
if p_amount is null or p_amount <= 0 then
raise exception 'e-recharge cost must be > 0 (got %)', p_amount;
end if;
select * into t from app.transactions where id = p_txn_id;
if t.id is null then raise exception 'txn not found'; end if;
v_provider := case upper(p_operator)
when 'ALFA' then 'ALFA_ERECHARGE'::app.float_provider
when 'TOUCH' then 'TOUCH_ERECHARGE'::app.float_provider
when 'OGERO' then 'OGERO_ERECHARGE'::app.float_provider
else null
end;
if v_provider is null then
-- Unmapped operator (IDM, CYBERIA, TERRANET): fall back to OMT_DIGITAL
-- so the recharge_require_movement trigger sees a negative leg.
v_provider := 'OMT_DIGITAL'::app.float_provider;
end if;
v_float := app._get_or_create_float(t.shop_id, v_provider, 'USD'::app.currency_code);
insert into app.float_movements(
float_id, shift_id, amount, ref_txn_id, reason
) values (
v_float, t.shift_id, -p_amount, p_txn_id,
'e-recharge ' || coalesce(p_operator, '?')
);
end;
$$;
revoke all on function app._erecharge_post_float(uuid, text, numeric) from public;
-- ---------------------------------------------------------------------
-- Re-define record_recharge to fold in voucher / e-float posting, and
-- the cash leg via the helper added in 0018.
-- ---------------------------------------------------------------------
create or replace function app.record_recharge(
p_shop uuid, p_till uuid, p_service_code text,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fee_usd numeric, p_fee_lbp numeric,
p_fx_rate numeric,
p_operator text, p_msisdn text, p_product_code text,
p_voucher_serial text, p_e_recharge_ref text,
p_unit_face_usd numeric, p_unit_cost_usd numeric,
p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
v_serial text := nullif(btrim(p_voucher_serial),'');
v_eref text := nullif(btrim(p_e_recharge_ref),'');
v_cost_usd numeric;
begin
if v_serial is null and v_eref is null then
raise exception 'either voucher_serial or e_recharge_ref is required';
end if;
if v_serial is not null and v_eref is not null then
raise exception 'pass either voucher_serial OR e_recharge_ref, not both';
end if;
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
p_operator, v_serial, null, p_msisdn, null, p_notes);
insert into app.recharge_details(
txn_id, operator, msisdn, product_code,
voucher_serial, e_recharge_provider_ref,
unit_face_value_usd, unit_cost_usd
) values (
v_txn, p_operator, p_msisdn, p_product_code,
v_serial, v_eref,
p_unit_face_usd, p_unit_cost_usd
);
-- ---- inventory / float coupling --------------------------------
if v_serial is not null then
perform app._sell_voucher_internal(v_txn, v_serial);
else
-- e-recharge: prefer recorded unit_cost_usd, fall back to gross_usd.
v_cost_usd := coalesce(nullif(p_unit_cost_usd,0), p_gross_usd);
perform app._erecharge_post_float(v_txn, p_operator, v_cost_usd);
end if;
-- ---- cash leg (re-uses helper from 0018) -----------------------
perform app._post_cash_for_txn(
v_txn, p_payment_method,
coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0)
);
return v_txn;
end;
$$;
revoke all on function app.record_recharge(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric,
text, text, text, text, text, numeric, numeric, text) from public;
grant execute on function app.record_recharge(uuid, uuid, text, app.payment_method,
numeric, numeric, numeric, numeric, numeric,
text, text, text, text, text, numeric, numeric, text) to authenticated;
-- ---------------------------------------------------------------------
-- Re-define record_goods_sale to fold in the sale_out stock movement
-- and the cash leg in the same transaction.
-- ---------------------------------------------------------------------
create or replace function app.record_goods_sale(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fx_rate numeric,
p_sku text, p_qty integer,
p_unit_cost_usd numeric, p_unit_price_usd numeric,
p_serial_number text,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_txn uuid;
begin
if p_qty is null or p_qty <= 0 then
raise exception 'qty must be > 0';
end if;
if p_sku is null or btrim(p_sku) = '' then
raise exception 'sku required';
end if;
v_txn := app._insert_txn(p_shop, p_till, 'GOODS_SALE', p_payment_method,
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, p_customer_id, p_notes);
insert into app.goods_sale_details(
txn_id, sku, qty, unit_cost_usd, unit_price_usd, serial_number
) values (
v_txn, p_sku, p_qty, p_unit_cost_usd, p_unit_price_usd, p_serial_number
);
-- Stock leg. The 0005 _stock_mov_before_insert trigger validates the
-- ref_txn_id points at a completed sale in the same shop, and the
-- _stock_on_hand_apply trigger refuses to go negative.
insert into app.stock_movements(
sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason
) values (
p_sku, p_shop, (select shift_id from app.transactions where id = v_txn),
'sale_out'::app.stock_movement_type,
-p_qty,
v_txn,
case when p_serial_number is not null
then 'goods sale serial=' || p_serial_number
else 'goods sale' end
);
-- Cash leg.
perform app._post_cash_for_txn(
v_txn, p_payment_method,
coalesce(p_gross_usd,0),
coalesce(p_gross_lbp,0)
);
return v_txn;
end;
$$;
revoke all on function app.record_goods_sale(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) from public;
grant execute on function app.record_goods_sale(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) to authenticated;
-- ---------------------------------------------------------------------
-- Repair sales also put cash in the till (parts + labour). The original
-- record_repair from 0013 inserts only the txn header + repair detail
-- and never posts cash, so REPAIR variances were silently absorbed by
-- the next cashier's drop. Wrap the existing function so it posts cash.
-- ---------------------------------------------------------------------
create or replace function app.record_repair(
p_shop uuid, p_till uuid,
p_payment_method app.payment_method,
p_gross_usd numeric, p_gross_lbp numeric,
p_fx_rate numeric,
p_device_type text, p_device_imei text,
p_issue_summary text, p_warranty_days integer,
p_customer_id uuid, p_notes text
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare v_txn uuid;
begin
v_txn := app._insert_txn(p_shop, p_till, 'REPAIR', p_payment_method,
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, p_customer_id, p_notes);
insert into app.repair_details(
txn_id, device_type, device_imei, issue_summary, warranty_days
) values (
v_txn, p_device_type, p_device_imei, p_issue_summary, p_warranty_days
);
perform app._post_cash_for_txn(
v_txn, p_payment_method,
coalesce(p_gross_usd,0),
coalesce(p_gross_lbp,0)
);
return v_txn;
end;
$$;
revoke all on function app.record_repair(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, text, text, integer, uuid, text) from public;
grant execute on function app.record_repair(uuid, uuid, app.payment_method,
numeric, numeric, numeric, text, text, text, integer, uuid, text) to authenticated;
@@ -0,0 +1,296 @@
-- =====================================================================
-- 0023_fx_rates_and_swap.sql
--
-- Two related holes around foreign exchange:
--
-- (1) The cashier types an arbitrary `fx_rate_used` on every USD/LBP
-- transaction. Nothing on the server compares it to the daily
-- posted rate. A cashier can rate a $100 sale at 1 USD = 90,000 LBP
-- while the till uses 1 USD = 89,500 LBP and pocket the spread.
--
-- (2) `cash_movement_type` has 'fx_swap_in' and 'fx_swap_out' but no
-- function posts them as a matched pair. A cashier swapping $100
-- out of the till for 8.95M LBP today does it manually with two
-- uncoupled cash_movements rows; the sign-guard added in 0019
-- catches gross sign mistakes but not amount mismatches.
--
-- This migration:
-- * adds `app.fx_rates(shop_id, effective_from, usd_to_lbp_rate,
-- tolerance_pct)` — append-only history of the shop's posted rate.
-- * adds `app.compute_fx_window(shop, effective)` returning the
-- accepted band [low, high] for the currently-active rate.
-- * adds a constraint trigger on `app.transactions` that, only when
-- a rate is published for the shop, enforces fx_rate_used falls
-- within the band whenever both gross_usd and gross_lbp are non-zero
-- (genuine cross-currency txn) OR for explicit FX swaps.
-- * adds `app.record_fx_swap(shop, till, p_usd_amount, p_lbp_amount,
-- p_fx_rate)` which posts both legs atomically and refuses the call
-- unless |p_usd_amount * fx_rate - p_lbp_amount| <= 1 LBP.
--
-- Shops that don't seed `fx_rates` keep working unchanged.
-- =====================================================================
set search_path = app, public;
create table if not exists app.fx_rates (
id uuid primary key default gen_random_uuid(),
shop_id uuid not null references app.shops(id) on delete cascade,
effective_from timestamptz not null default now(),
effective_to timestamptz,
-- Number of LBP per 1 USD (e.g. 89500).
usd_to_lbp_rate numeric(14,2) not null check (usd_to_lbp_rate > 0),
-- Allowed deviation either side of the posted rate, as a percent
-- (e.g. 1.0 = ±1%). Defaults to 0.5%.
tolerance_pct numeric(6,3) not null default 0.5
check (tolerance_pct >= 0 and tolerance_pct <= 25),
note text,
created_at timestamptz not null default now(),
created_by uuid not null references auth.users(id) default auth.uid(),
check (effective_to is null or effective_to > effective_from)
);
create index if not exists idx_fx_rates_lookup
on app.fx_rates(shop_id, effective_from desc);
alter table app.fx_rates enable row level security;
alter table app.fx_rates force row level security;
drop policy if exists fx_rates_select on app.fx_rates;
create policy fx_rates_select on app.fx_rates
for select using (
app.has_any_role_in_shop(shop_id,
array['cashier','manager','owner']::app.business_role[])
);
drop policy if exists fx_rates_write on app.fx_rates;
create policy fx_rates_write on app.fx_rates
for all using (
app.has_any_role_in_shop(shop_id,
array['manager','owner']::app.business_role[])
) with check (
app.has_any_role_in_shop(shop_id,
array['manager','owner']::app.business_role[])
);
-- Append-only on history: only effective_to may be moved forward. Same
-- pattern as fee_schedule in 0021.
create or replace function app._fx_rates_immutable()
returns trigger language plpgsql as $$
begin
if tg_op = 'DELETE' then
raise exception 'fx_rates is append-only';
end if;
if (old.shop_id, old.usd_to_lbp_rate, old.tolerance_pct, old.effective_from)
is distinct from
(new.shop_id, new.usd_to_lbp_rate, new.tolerance_pct, new.effective_from)
then
raise exception 'fx_rates columns are immutable; close the row and insert a new one';
end if;
if old.effective_to is not null then
raise exception 'fx_rates row already closed';
end if;
if new.effective_to is null then
raise exception 'effective_to must be set to close an fx_rates row';
end if;
return new;
end;
$$;
drop trigger if exists trg_fx_rates_immutable on app.fx_rates;
create trigger trg_fx_rates_immutable
before update or delete on app.fx_rates
for each row execute function app._fx_rates_immutable();
-- ---------------------------------------------------------------------
-- Lookup helper: return the active rate band for a shop right now.
-- ---------------------------------------------------------------------
create or replace function app.current_fx_band(p_shop uuid)
returns table (
rate numeric,
band_low numeric,
band_high numeric,
tolerance_pct numeric,
rate_id uuid
)
language sql
stable
security definer
set search_path = app, public
as $$
select
f.usd_to_lbp_rate,
f.usd_to_lbp_rate * (1 - f.tolerance_pct / 100.0),
f.usd_to_lbp_rate * (1 + f.tolerance_pct / 100.0),
f.tolerance_pct,
f.id
from app.fx_rates f
where f.shop_id = p_shop
and f.effective_from <= now()
and (f.effective_to is null or f.effective_to > now())
order by f.effective_from desc
limit 1;
$$;
revoke all on function app.current_fx_band(uuid) from public;
grant execute on function app.current_fx_band(uuid) to authenticated;
-- ---------------------------------------------------------------------
-- Constraint trigger on app.transactions:
-- when a posted rate exists for the shop, fx_rate_used must lie within
-- the band whenever the txn is a genuine USD/LBP cross-currency event.
-- ---------------------------------------------------------------------
create or replace function app._txn_fx_rate_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
band record;
begin
-- Skip non-money / void rows.
if new.status <> 'completed' then return null; end if;
if coalesce(new.fx_rate_used, 0) = 0 then return null; end if;
-- Only police txns that actually mix the two currencies, or where
-- the cashier deliberately recorded an fx_rate (e.g. payment in USD,
-- gross in LBP).
if not (coalesce(new.gross_usd,0) <> 0 and coalesce(new.gross_lbp,0) <> 0)
and new.service_code <> 'FX_SWAP'
then
-- Some single-currency txns also store the day's rate for
-- reporting; still validate it against the band so a wildly wrong
-- value can't slip through.
null;
end if;
select * into band from app.current_fx_band(new.shop_id);
if band.rate_id is null then
return null; -- shop hasn't published a rate yet
end if;
if new.fx_rate_used < band.band_low or new.fx_rate_used > band.band_high then
raise exception
'fx_rate_used % outside posted band [%, %] (rate %, tolerance % %%)',
new.fx_rate_used, band.band_low, band.band_high,
band.rate, band.tolerance_pct;
end if;
return null;
end;
$$;
drop trigger if exists trg_txn_fx_rate_check on app.transactions;
create constraint trigger trg_txn_fx_rate_check
after insert on app.transactions
deferrable initially deferred
for each row execute function app._txn_fx_rate_check();
-- ---------------------------------------------------------------------
-- record_fx_swap: post the two cash_movements legs atomically.
-- direction:
-- p_usd_out > 0 means USD leaves the till and LBP comes in
-- -> fx_swap_out USD, fx_swap_in LBP
-- p_usd_out < 0 means USD comes into the till and LBP leaves
-- -> fx_swap_in USD, fx_swap_out LBP
-- The amounts on both sides must agree to within 1 LBP at p_fx_rate,
-- and p_fx_rate must lie within the posted band (when one exists).
-- ---------------------------------------------------------------------
do $$ begin
-- Add FX_SWAP service code if not already present, so the txn header
-- has a real service to attach to (the recorded txn carries no
-- product detail row).
if not exists (select 1 from app.services where code = 'FX_SWAP') then
insert into app.services(code, name, category)
values ('FX_SWAP', 'Currency Exchange', 'cash_ops');
end if;
end $$;
create or replace function app.record_fx_swap(
p_shop uuid,
p_till uuid,
p_usd_out numeric, -- + USD leaves till, - USD enters till
p_lbp_in numeric, -- + LBP enters till when usd_out>0, must be opposite sign
p_fx_rate numeric,
p_notes text default null
) returns uuid
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_shift uuid;
v_diff numeric;
v_txn uuid;
band record;
begin
if p_usd_out is null or p_lbp_in is null or p_fx_rate is null or p_fx_rate <= 0 then
raise exception 'usd_out, lbp_in and positive fx_rate are required';
end if;
if p_usd_out = 0 then
raise exception 'usd_out cannot be 0';
end if;
if sign(p_usd_out) = sign(p_lbp_in) then
raise exception 'usd_out and lbp_in must have opposite signs (one in, one out)';
end if;
-- Sanity: |usd_out| * rate must equal |lbp_in| within 1 LBP.
v_diff := abs(abs(p_usd_out) * p_fx_rate - abs(p_lbp_in));
if v_diff > 1 then
raise exception
'fx swap mismatch: |usd_out|*rate = % but |lbp_in| = % (diff %)',
abs(p_usd_out) * p_fx_rate, abs(p_lbp_in), v_diff;
end if;
-- Rate band (only enforced when a rate is published).
select * into band from app.current_fx_band(p_shop);
if band.rate_id is not null
and (p_fx_rate < band.band_low or p_fx_rate > band.band_high)
then
raise exception
'fx_rate % outside posted band [%, %] (rate %, tolerance % %%)',
p_fx_rate, band.band_low, band.band_high, band.rate, band.tolerance_pct;
end if;
-- Caller must have an open shift on this till.
select id into v_shift from app.shifts
where till_id = p_till and shop_id = p_shop
and user_id = auth.uid() and status = 'open'
order by opened_at desc limit 1;
if v_shift is null then
raise exception 'no open shift for this till';
end if;
-- Create a header txn (gross_usd/lbp = 0 — money does not enter or
-- leave the shop, just changes currency). The fx_rate is recorded.
v_txn := app._insert_txn(p_shop, p_till, 'FX_SWAP', 'cash_usd'::app.payment_method,
0, 0, 0, 0, 0, 0, p_fx_rate,
null, null, null, null, null, p_notes);
-- USD leg.
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (
v_shift,
case when p_usd_out > 0
then 'fx_swap_out'::app.cash_movement_type
else 'fx_swap_in'::app.cash_movement_type end,
'USD'::app.currency_code,
-p_usd_out, -- p_usd_out is the OUTflow amount
v_txn,
'fx swap'
);
-- LBP leg.
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
values (
v_shift,
case when p_lbp_in > 0
then 'fx_swap_in'::app.cash_movement_type
else 'fx_swap_out'::app.cash_movement_type end,
'LBP'::app.currency_code,
p_lbp_in,
v_txn,
'fx swap'
);
return v_txn;
end;
$$;
revoke all on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) from public;
grant execute on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) to authenticated;
@@ -0,0 +1,331 @@
-- =====================================================================
-- 0024_idempotency_and_self_deal.sql
--
-- Two related fraud vectors not yet closed:
--
-- (A) Idempotency / replay. Today the cashier can post the same OMT
-- payout code twice in the same shift and pocket the difference,
-- or post the same WU MTCN twice and let the second one fail to
-- reconcile silently. Nothing on the server enforces uniqueness
-- of `(shop_id, external_ref_provider, external_ref)` for active
-- money-transfer transactions.
--
-- (B) Self-deal. A cashier processing transfers on their own KYC ID
-- (or as the named beneficiary of a payout, or as the sender of
-- a high-value send to themselves) is the classic skim pattern
-- across all Lebanese MFS shops. The DB has all the data — the
-- cashier's user_profiles row, plus sender_id_number /
-- beneficiary_id_number on the detail row — but never compares
-- them.
--
-- This migration:
-- * adds nullable `id_type` / `id_number` / `phone_kyc` columns to
-- `app.user_profiles` (the cashier's own KYC),
-- * unique index on (shop_id, external_ref_provider, external_ref)
-- covering only completed (or pending) money-transfer service
-- codes,
-- * deferred constraint trigger that rejects an OMT/WU/Whish/bill
-- txn whose sender or beneficiary ID matches the cashier's own
-- KYC, unless an `app.system_settings` flag explicitly allows it
-- AND the txn is approved by a manager.
-- =====================================================================
set search_path = app, public;
-- ---------------------------------------------------------------------
-- (A) idempotent external_ref
-- ---------------------------------------------------------------------
-- A *partial unique* index limited to:
-- * completed or pending status (voided rows can re-use a code if
-- the original was void-reversed, which is desired),
-- * money-transfer / bill service codes (recharges and goods sales
-- don't carry meaningful external_ref uniqueness).
create unique index if not exists ux_txn_external_ref_active
on app.transactions (shop_id, external_ref_provider, external_ref)
where external_ref is not null
and external_ref_provider is not null
and status <> 'voided'
and service_code in (
'OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE',
'WHISH_SEND','OMT_BILL','EDL_BILL'
);
-- ---------------------------------------------------------------------
-- (B) self-deal: extend user_profiles with cashier KYC
-- ---------------------------------------------------------------------
alter table app.user_profiles
add column if not exists id_type app.id_doc_type,
add column if not exists id_number text,
add column if not exists phone_kyc text;
create index if not exists idx_user_profiles_kyc_id
on app.user_profiles(id_type, id_number)
where id_number is not null;
-- Allow a manager to bypass self-deal blocking for a specific txn by
-- setting this knob; default is to block.
insert into app.system_settings(key, value)
values ('self_deal_block_enabled', 'true')
on conflict (key) do nothing;
-- ---------------------------------------------------------------------
-- Helper: does an ID belong to the cashier who created the txn?
-- ---------------------------------------------------------------------
create or replace function app._is_cashier_self(
p_user_id uuid,
p_id_type app.id_doc_type,
p_id_number text,
p_phone text
) returns boolean
language sql
stable
security definer
set search_path = app, public
as $$
select exists (
select 1 from app.user_profiles up
where up.user_id = p_user_id
and (
(p_id_number is not null
and up.id_number is not null
and up.id_type = p_id_type
and lower(btrim(up.id_number)) = lower(btrim(p_id_number)))
or (p_phone is not null
and up.phone_kyc is not null
and regexp_replace(up.phone_kyc, '\D', '', 'g')
= regexp_replace(p_phone, '\D', '', 'g'))
)
);
$$;
revoke all on function app._is_cashier_self(uuid, app.id_doc_type, text, text) from public;
-- ---------------------------------------------------------------------
-- Constraint trigger: fired AFTER INSERT on the detail rows that carry
-- counter-party identity. Each branch checks the txn owner against
-- the recorded sender / beneficiary KYC.
-- ---------------------------------------------------------------------
create or replace function app._omt_send_self_deal_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
block boolean;
begin
select coalesce(value::boolean, true) into block
from app.system_settings where key = 'self_deal_block_enabled';
if not block then return null; end if;
select * into t from app.transactions where id = new.txn_id;
if t.id is null then return null; end if;
if app._is_cashier_self(t.user_id, new.sender_id_type, new.sender_id_number, new.sender_phone)
then
raise exception
'self-deal blocked: cashier (% ) is the SENDER on txn % — manager must process this transfer',
t.user_id, new.txn_id;
end if;
-- A cashier sending to themselves as beneficiary is also self-deal.
-- We only have name+phone for the beneficiary on send rows, so match
-- on phone (most reliable) when present.
if new.beneficiary_phone is not null
and app._is_cashier_self(t.user_id, null, null, new.beneficiary_phone)
then
raise exception
'self-deal blocked: cashier is the BENEFICIARY phone on txn %',
new.txn_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_omt_send_self_deal on app.omt_send_details;
create constraint trigger trg_omt_send_self_deal
after insert on app.omt_send_details
deferrable initially deferred
for each row execute function app._omt_send_self_deal_check();
create or replace function app._omt_receive_self_deal_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
block boolean;
begin
select coalesce(value::boolean, true) into block
from app.system_settings where key = 'self_deal_block_enabled';
if not block then return null; end if;
select * into t from app.transactions where id = new.txn_id;
if t.id is null then return null; end if;
if app._is_cashier_self(t.user_id,
new.beneficiary_id_type, new.beneficiary_id_number, new.beneficiary_phone)
then
raise exception
'self-deal blocked: cashier is the PAYOUT BENEFICIARY on txn % — manager must process',
new.txn_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_omt_receive_self_deal on app.omt_receive_details;
create constraint trigger trg_omt_receive_self_deal
after insert on app.omt_receive_details
deferrable initially deferred
for each row execute function app._omt_receive_self_deal_check();
-- ---------------------------------------------------------------------
-- Manager-only knob: temporarily allow a single self-deal transfer
-- (e.g. owner sending themselves their own salary). Auto-resets after
-- one INSERT via a session GUC.
-- ---------------------------------------------------------------------
create or replace function app.manager_allow_next_self_deal(
p_manager_pin text,
p_shop uuid
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
if not app.has_role_in_shop(p_shop, 'manager')
and not app.has_role_in_shop(p_shop, 'owner')
then
raise exception 'manager or owner role required';
end if;
if not app.verify_my_pin(p_manager_pin) then
raise exception 'invalid manager PIN';
end if;
perform set_config('app.self_deal_override', 'on', true); -- session GUC
perform app.log_auth_event('self_deal_override_granted', p_shop, null, '{}'::jsonb);
end;
$$;
revoke all on function app.manager_allow_next_self_deal(text, uuid) from public;
grant execute on function app.manager_allow_next_self_deal(text, uuid) to authenticated;
-- Wire the override into the self-deal checkers.
create or replace function app._self_deal_overridden()
returns boolean
language sql
stable
as $$
select coalesce(current_setting('app.self_deal_override', true), 'off') = 'on';
$$;
create or replace function app._omt_send_self_deal_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
block boolean;
begin
if app._self_deal_overridden() then
perform set_config('app.self_deal_override', 'off', true);
return null;
end if;
select coalesce(value::boolean, true) into block
from app.system_settings where key = 'self_deal_block_enabled';
if not block then return null; end if;
select * into t from app.transactions where id = new.txn_id;
if t.id is null then return null; end if;
if app._is_cashier_self(t.user_id, new.sender_id_type, new.sender_id_number, new.sender_phone) then
raise exception
'self-deal blocked: cashier is the SENDER on txn %', new.txn_id;
end if;
if new.beneficiary_phone is not null
and app._is_cashier_self(t.user_id, null, null, new.beneficiary_phone)
then
raise exception
'self-deal blocked: cashier is the BENEFICIARY phone on txn %', new.txn_id;
end if;
return null;
end;
$$;
create or replace function app._omt_receive_self_deal_check()
returns trigger
language plpgsql
security definer
set search_path = app, public
as $$
declare
t app.transactions%rowtype;
block boolean;
begin
if app._self_deal_overridden() then
perform set_config('app.self_deal_override', 'off', true);
return null;
end if;
select coalesce(value::boolean, true) into block
from app.system_settings where key = 'self_deal_block_enabled';
if not block then return null; end if;
select * into t from app.transactions where id = new.txn_id;
if t.id is null then return null; end if;
if app._is_cashier_self(t.user_id,
new.beneficiary_id_type, new.beneficiary_id_number, new.beneficiary_phone)
then
raise exception
'self-deal blocked: cashier is the PAYOUT BENEFICIARY on txn %', new.txn_id;
end if;
return null;
end;
$$;
-- ---------------------------------------------------------------------
-- Convenience RPC for the manager UI to set/update a cashier's KYC.
-- ---------------------------------------------------------------------
create or replace function app.set_user_kyc(
p_user_id uuid,
p_shop uuid,
p_id_type app.id_doc_type,
p_id_number text,
p_phone_kyc text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
begin
if not app.has_role_in_shop(p_shop, 'manager')
and not app.has_role_in_shop(p_shop, 'owner')
then
raise exception 'manager or owner role required';
end if;
if p_id_number is null or btrim(p_id_number) = '' then
raise exception 'id_number required';
end if;
-- the user must actually be assigned to this shop
if not exists(
select 1 from app.user_shop_assignments
where user_id = p_user_id and shop_id = p_shop
) then
raise exception 'user is not assigned to that shop';
end if;
update app.user_profiles
set id_type = p_id_type,
id_number = btrim(p_id_number),
phone_kyc = nullif(btrim(p_phone_kyc),'')
where user_id = p_user_id;
perform app.log_auth_event('user_kyc_updated', p_shop, null,
jsonb_build_object('user_id', p_user_id, 'id_type', p_id_type));
end;
$$;
revoke all on function app.set_user_kyc(uuid, uuid, app.id_doc_type, text, text) from public;
grant execute on function app.set_user_kyc(uuid, uuid, app.id_doc_type, text, text) to authenticated;

Some files were not shown because too many files have changed in this diff Show More