Compare commits
23 Commits
2fbf7e6bce
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 34ea64c4d4 | |||
| d15273e2a6 | |||
| 5b7a2b847f | |||
| 6f8a5fe969 | |||
| 418ad36b02 | |||
| ff75cf2fa7 | |||
| db9387c01a | |||
| dc500972e0 | |||
| 83ec5ecd36 | |||
| bae21ee62c | |||
| 5e0e44c647 | |||
| 21d530cd6d | |||
| 9e3e59a804 | |||
| ee5bb5beb8 | |||
| 1fe2421cc4 | |||
| 0f609d57bb | |||
| 38facd3d9d | |||
| f865837b27 | |||
| cb962466b3 | |||
| 8fe50af341 | |||
| 472573a1a8 | |||
| 8817c0f52f | |||
| d4808b3e46 |
+19
-1
@@ -12,7 +12,25 @@ JWT_ACCESS_TTL=15m
|
|||||||
JWT_REFRESH_TTL=30d
|
JWT_REFRESH_TTL=30d
|
||||||
DELIVERY_MAX_DEVICES=2
|
DELIVERY_MAX_DEVICES=2
|
||||||
|
|
||||||
# ---- Safaricom Daraja ----
|
# ---- Platform operator ----
|
||||||
|
# Comma-separated emails with cross-tenant access to /platform. The account
|
||||||
|
# must also carry the platformAdmin flag, set only by `npm run seed` or
|
||||||
|
# `npm run migrate:identity`. Removing an email here revokes access.
|
||||||
|
# Public signup refuses these addresses.
|
||||||
|
PLATFORM_ADMIN_EMAILS=
|
||||||
|
|
||||||
|
# ---- Invite links ----
|
||||||
|
# Public base URL used in staff invite links. Optional: defaults to the
|
||||||
|
# address the request came in on.
|
||||||
|
APP_URL=
|
||||||
|
|
||||||
|
# ---- M-Pesa (optional, off for the pilot) ----
|
||||||
|
# M-Pesa STK Push is out of scope for the pilot. While this is false every
|
||||||
|
# /api/mpesa route returns 404 and the Daraja variables below are ignored.
|
||||||
|
# If you turn it on in production, DARAJA_CALLBACK_IP_ALLOWLIST is required.
|
||||||
|
FEATURE_MPESA=false
|
||||||
|
|
||||||
|
# ---- Safaricom Daraja (only read when FEATURE_MPESA=true) ----
|
||||||
DARAJA_ENV=sandbox
|
DARAJA_ENV=sandbox
|
||||||
DARAJA_CONSUMER_KEY=xxxxxxxxxxxxxxxx
|
DARAJA_CONSUMER_KEY=xxxxxxxxxxxxxxxx
|
||||||
DARAJA_CONSUMER_SECRET=xxxxxxxxxxxxxxxx
|
DARAJA_CONSUMER_SECRET=xxxxxxxxxxxxxxxx
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Pilot readiness changes
|
||||||
|
|
||||||
|
This branch (`fix/pilot-readiness`) closes the gaps found when the MVP was
|
||||||
|
reviewed against `PRD.md` and `REVIEW.md`. This page records what changed,
|
||||||
|
the decisions taken along the way, and what is still open.
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
### Tenant isolation and roles (PRD 5.1)
|
||||||
|
- Every role-limited API route goes through `authorize()` in
|
||||||
|
`src/lib/server/auth.ts`: **401** when signed out, **403** for the wrong
|
||||||
|
role. Cashiers can no longer read profit, expenses or purchases, or
|
||||||
|
record expenses and purchases. Only the owner creates or edits products
|
||||||
|
and prices.
|
||||||
|
- `PATCH /api/users/:id` deactivates and reactivates staff (managers: only
|
||||||
|
their own cashiers). Deactivating or moving a user revokes their device
|
||||||
|
sessions.
|
||||||
|
- `PATCH /api/branches/:id` renames a branch (owner only).
|
||||||
|
- Login and token refresh refuse an inactive or `SUSPENDED` business.
|
||||||
|
- Public signup refuses any address in `PLATFORM_ADMIN_EMAILS`.
|
||||||
|
- Ids in query strings and bodies are validated (400, not a 500), inventory
|
||||||
|
routes check the product belongs to the tenant, and reconciliation
|
||||||
|
lookups are tenant-scoped.
|
||||||
|
|
||||||
|
### Stock ledger and transfers (PRD 5.2)
|
||||||
|
- `StockMovement` is the ledger: a branch's stock of a product is the sum
|
||||||
|
of its movements. Each movement names its cause, and `(type, refId)` is
|
||||||
|
unique, so a replayed sale or retried transfer never moves stock twice.
|
||||||
|
- Sales and confirmed M-Pesa payments deduct stock; purchases of a
|
||||||
|
catalogue product add it.
|
||||||
|
- `POST /api/stock/transfers` moves stock between branches.
|
||||||
|
`POST /api/stock/adjustments` records opening stock, breakage and
|
||||||
|
physical counts. `GET /api/stock` returns stock per branch with LOW/OUT
|
||||||
|
flags (per-product `lowStockThreshold`, default 10).
|
||||||
|
- The admin **Stock** page (`/admin/stock`) covers all of this, plus the
|
||||||
|
existing morning dispatch and evening count. The dashboard shows stock
|
||||||
|
on hand per branch.
|
||||||
|
|
||||||
|
### Offline POS and sync (PRD 5.3, 5.5)
|
||||||
|
- `POST /api/transactions/cash` answers each sale with `saved`,
|
||||||
|
`duplicate` or `rejected`. Previously a replayed sale was skipped
|
||||||
|
silently, the device never marked it synced, and once more than 100 rows
|
||||||
|
piled up every sync failed.
|
||||||
|
- The device queue (`src/lib/offlineQueue.ts`) is keyed by cashier and
|
||||||
|
branch, sends only the signed-in cashier's sales, chunks to 50 per
|
||||||
|
request, and stops retrying rejected sales (they are shown to the
|
||||||
|
cashier instead).
|
||||||
|
- A service worker (`public/sw.js`) caches the app shell, and the last
|
||||||
|
session and product list are kept on the phone, so the terminal opens
|
||||||
|
and sells with no network.
|
||||||
|
- Sales can contain several products, the customer phone is optional for
|
||||||
|
cash, and the terminal shows a printable receipt.
|
||||||
|
|
||||||
|
### Other
|
||||||
|
- "Today" and "this month" follow Africa/Nairobi, not the server's UTC
|
||||||
|
clock (`src/lib/server/businessDay.ts`).
|
||||||
|
- M-Pesa is behind `FEATURE_MPESA` (off by default; out of scope for the
|
||||||
|
pilot per PRD section 4).
|
||||||
|
|
||||||
|
## Decisions taken
|
||||||
|
|
||||||
|
| Decision | Why |
|
||||||
|
|---|---|
|
||||||
|
| Offline sales are always accepted, even if they take stock below zero. | The sale already happened. Refusing it would lose real cash. Negative stock is shown on the Stock page and fixed with a physical count. |
|
||||||
|
| Transfers are refused when the sending branch lacks the stock. | Transfers are made online by a manager, so they can be checked. The check and the move run under a short per-branch, per-product lock (`withStockLock` in `src/lib/server/stock.ts`), so simultaneous transfers cannot together overdraw a branch; physical counts take the same lock. |
|
||||||
|
| The server price is authoritative; a different price shown on the device is kept (`clientUnitPrice`) and flagged (`priceMismatch`). | Trusting the device's price would let a cashier under-charge. Overwriting it silently would hide a cash discrepancy. |
|
||||||
|
| A queued sale stays with the branch where it was recorded, even if the cashier has since moved. | That is where the stock left and the cash was taken. |
|
||||||
|
| A product deactivated while a phone was offline is still recorded at its price. | Same reason as the first row: the sale happened. |
|
||||||
|
|
||||||
|
## Rolling out to an existing database
|
||||||
|
- No migration is needed: new fields have defaults, and Mongoose creates
|
||||||
|
the new indexes on start-up.
|
||||||
|
- Existing branches start with **no stock on record**. Before the pilot,
|
||||||
|
each branch should record an opening count on the Stock page.
|
||||||
|
- Cashiers' phones upgrade their offline queue automatically; any unsynced
|
||||||
|
sales from the old version are sent on the next sync.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- `npm test` runs 62 API tests against an in-memory MongoDB, covering role
|
||||||
|
enforcement, tenant isolation for sales, stock and transfers, suspension,
|
||||||
|
the sync protocol (including a 120-sale backlog replayed twice), and the
|
||||||
|
business day.
|
||||||
|
- The offline flow was also checked end to end in headless Chrome: sign
|
||||||
|
in, stop the server, reload the terminal, record two sales, restart the
|
||||||
|
server, confirm they sync once and deduct stock.
|
||||||
|
|
||||||
|
## Follow-ups from the 11 October 2026 test report
|
||||||
|
|
||||||
|
The initial test report (run on the build before these changes) raised
|
||||||
|
seven findings and a retest plan. All of them are addressed on
|
||||||
|
`fix/test-report-followups`:
|
||||||
|
|
||||||
|
| Finding | What changed |
|
||||||
|
|---|---|
|
||||||
|
| F-01 Trial allows one branch | A business on its free trial may run **2 active branches** whatever its tier (`TRIAL_MAX_BRANCHES` in `src/lib/tiers.ts`). After the trial the tier's limit applies to adding branches. |
|
||||||
|
| F-02 No current stock | Done in the previous change (stock ledger). Purchases recorded before the ledger existed are added by `npm run backfill:stock`. |
|
||||||
|
| F-03 Reversed report dates | Refused in the page and by the API (400). The page labels which period the cards show. Report dates are Nairobi business days. |
|
||||||
|
| F-04 No way back for deactivated products | Products page lists deactivated products under a toggle, with Reactivate. Deactivating warns about stock still on hand; that stock stays on the Stock page and can be transferred or counted out. |
|
||||||
|
| F-05 Eggs shown as litres | Products carry a unit (L, ml, kg, g, pcs, tray, pack), chosen when the product is created and shown everywhere. |
|
||||||
|
| F-06 No branch or staff removal | Staff: deactivate/reactivate (previous change). Branches: rename and deactivate/reactivate on the Branches page. A branch can only be deactivated once it has no stock and no active staff; its history stays in reports. |
|
||||||
|
| F-07 Purchases shown as cost of goods | Cost of goods sold covers only units sold (now first-in-first-out per branch; see "Cost of goods sold" below). Purchases in the period are shown separately as "Stock purchased", with a "Cash surplus" line. |
|
||||||
|
| STA-04 No invites | Staff and new branch managers can be invited: the owner or manager gets a one-time link (7 days) to send, and the person sets their own password. A new link can be made from Staff. |
|
||||||
|
| CAT-12 Zero price, duplicates | Prices must be above zero; product names are unique per business, ignoring case. |
|
||||||
|
| OFF-02 Local stock deduction | The till shows each product's stock and deducts every sale immediately, online or offline. |
|
||||||
|
| POS-02 / INV-05 Oversell, cancel | The till warns before selling more than the recorded stock and asks to confirm (it does not block, because the sale may already have happened). A "Clear sale" button empties the cart. |
|
||||||
|
|
||||||
|
`tests/retest-plan.test.ts` runs the report's completion and retest plan
|
||||||
|
(online sale, transfer, two offline sales replayed twice, a Branch B sale)
|
||||||
|
and checks every balance and total it predicts.
|
||||||
|
|
||||||
|
### Before re-running the test report's plan on ZERRO
|
||||||
|
1. Deploy, then run `npm run backfill:stock` once so the existing test
|
||||||
|
purchases count as stock. Run it **before** anyone records an opening
|
||||||
|
count, or those units would be counted twice.
|
||||||
|
2. ZERRO is trialling, so it can now add Branch B without a plan change.
|
||||||
|
3. Create the manager and cashier logins with invite links from Staff.
|
||||||
|
|
||||||
|
## Branch prices
|
||||||
|
|
||||||
|
Owners set a different price per branch on **Products -> Branch prices**
|
||||||
|
(`/admin/prices`, API `/api/branch-prices`). Setting a price back to the
|
||||||
|
default removes the override. Branch managers see their own branch's
|
||||||
|
prices read-only.
|
||||||
|
|
||||||
|
- It is a Growth/Enterprise feature (as the plan list already advertised),
|
||||||
|
also available during the free trial. After a trial on Starter, existing
|
||||||
|
branch prices keep applying and can be reset, but not changed.
|
||||||
|
- The till, cash sales and M-Pesa all read the branch price. A phone picks
|
||||||
|
up a change the next time it loads products; a sale it records offline in
|
||||||
|
the meantime is kept at the server's price and flagged as a price
|
||||||
|
mismatch.
|
||||||
|
- The Products page always shows default prices.
|
||||||
|
|
||||||
|
## People in more than one business
|
||||||
|
|
||||||
|
Email and phone are unique **within a business**, not across the platform,
|
||||||
|
so one person can work for two businesses. Each account has its own
|
||||||
|
password and role.
|
||||||
|
|
||||||
|
- Login looks the email up in every business. If the password matches one
|
||||||
|
account, it signs straight in; if it matches several, the login page asks
|
||||||
|
which business. Someone with a wrong password learns nothing about which
|
||||||
|
businesses an email belongs to, and a wrong guess never resets the
|
||||||
|
failed-login count of another business's account.
|
||||||
|
- Signup and staff creation only check for duplicates inside the business,
|
||||||
|
so they no longer reveal whether an email or phone is registered
|
||||||
|
elsewhere.
|
||||||
|
- Platform-operator access now needs the `platformAdmin` flag on the
|
||||||
|
account as well as the email in `PLATFORM_ADMIN_EMAILS`. Without the
|
||||||
|
flag, any owner could create a staff account with the operator's email
|
||||||
|
in their own business. The flag is only set by `npm run seed` and
|
||||||
|
`npm run migrate:identity`, never through the app.
|
||||||
|
|
||||||
|
**Deploying this change:** run `npm run migrate:identity` straight after
|
||||||
|
the deploy. It flags the existing operator accounts and swaps the old
|
||||||
|
platform-wide unique indexes for per-business ones. Until it runs,
|
||||||
|
operators cannot open the Platform console and a second business cannot
|
||||||
|
reuse an email. It is safe to re-run.
|
||||||
|
|
||||||
|
## Tenant isolation is enforced
|
||||||
|
|
||||||
|
Every model that holds one business's data carries a guard
|
||||||
|
(`src/lib/server/tenantGuard.ts`). A query that does not name exactly one
|
||||||
|
business (`businessId: <id>`) throws `TenantScopeError` before it reaches
|
||||||
|
the database, so a route that forgets the business filter fails loudly in
|
||||||
|
development and in the tests instead of reading or changing another
|
||||||
|
business's data.
|
||||||
|
|
||||||
|
- Guarded: User, Branch, Product, BranchPricing, Transaction,
|
||||||
|
DailyInventory, Expense, Purchase, StockMovement, StockTransfer. A test
|
||||||
|
fails if a new model with a `businessId` field is added without the guard.
|
||||||
|
- Queries that genuinely span businesses must be wrapped in
|
||||||
|
`crossTenant("reason", ...)`: login's email lookup, refresh-session and
|
||||||
|
invite-token lookups, the M-Pesa callback, the platform console, and the
|
||||||
|
maintenance scripts. `git grep crossTenant` lists every one.
|
||||||
|
- Related records are loaded with `scopedPopulate`, so a populated branch,
|
||||||
|
product or staff name is also restricted to the business.
|
||||||
|
- `tests/isolation.test.ts` sweeps every read route with another business's
|
||||||
|
ids (nothing of theirs comes back) and every write route (all refused,
|
||||||
|
their data unchanged).
|
||||||
|
- Not guarded, by design: Business (the tenant itself, always looked up by
|
||||||
|
the session's id), UserSession, MpesaCallbackLog and StockLock.
|
||||||
|
|
||||||
|
## Cost of goods sold
|
||||||
|
|
||||||
|
The profit report costs sales first-in-first-out, per branch
|
||||||
|
(`src/lib/server/costing.ts`). It replays the stock ledger in order. Each
|
||||||
|
branch keeps queues of stock at the price it was bought at, and a sale
|
||||||
|
takes the oldest first.
|
||||||
|
|
||||||
|
- **Transfers carry their cost.** Stock moved between branches arrives at
|
||||||
|
what the sending branch paid.
|
||||||
|
- **Opening stock and upward counts** are valued at the product's purchase
|
||||||
|
price at the time (or its first purchase price, if they came first).
|
||||||
|
- **A sale recorded before its stock** (e.g. an offline sale before the
|
||||||
|
purchase was entered) is costed by the next stock to arrive at that
|
||||||
|
branch. Until then it counts as "units with no purchase cost on record".
|
||||||
|
- **Stock written off** by counts or adjustments is shown as "Stock losses"
|
||||||
|
and deducted in net profit. It is not part of cost of goods sold.
|
||||||
|
- Each report replays the business's whole stock history. That is fast at
|
||||||
|
pilot volumes; a business with years of history would want stored cost
|
||||||
|
layers instead.
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
- **M-Pesa, if turned on.** Only one product per payment. A prompt that
|
||||||
|
times out still needs a manual check before taking cash.
|
||||||
|
- **Not modelled yet:** email verification at signup, sending invites by
|
||||||
|
email (links are shared by hand, e.g. WhatsApp), empty-bottle and deposit
|
||||||
|
tracking, home-screen icons for the PWA.
|
||||||
Generated
+1778
-1
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -8,7 +8,11 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"seed": "tsx scripts/seed.ts",
|
"seed": "tsx scripts/seed.ts",
|
||||||
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts"
|
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts",
|
||||||
|
"migrate:identity": "tsx scripts/migrate-per-business-identity.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
@@ -29,10 +33,12 @@
|
|||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"mongodb-memory-server": "^10.1.4",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"dotenv": "^16.4.5"
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* AquaFlow service worker: lets the cashier terminal open and run with no network.
|
||||||
|
*
|
||||||
|
* - Page loads (navigations): network first, falling back to the cached copy,
|
||||||
|
* and to the cached /terminal page for anything else, so a cashier who opens
|
||||||
|
* the app offline still lands on the till.
|
||||||
|
* - Next.js build assets (/_next/static/*): cache first. They are content-hashed
|
||||||
|
* and immutable, so a cached copy is always correct.
|
||||||
|
* - API calls (/api/*) and every non-GET request: never cached. Sales go through
|
||||||
|
* the IndexedDB queue in the page, not through this worker.
|
||||||
|
*
|
||||||
|
* Bump CACHE_VERSION to drop every cached page and asset on the next visit.
|
||||||
|
*/
|
||||||
|
const CACHE_VERSION = "aquaflow-v1";
|
||||||
|
const APP_SHELL = ["/terminal", "/login"];
|
||||||
|
|
||||||
|
async function cacheShell() {
|
||||||
|
const cache = await caches.open(CACHE_VERSION);
|
||||||
|
for (const path of APP_SHELL) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(path, { credentials: "same-origin" });
|
||||||
|
if (!res.ok) continue;
|
||||||
|
await cache.put(path, res.clone());
|
||||||
|
// Also cache the scripts and styles the page needs to boot.
|
||||||
|
const html = await res.text();
|
||||||
|
const assets = Array.from(html.matchAll(/(?:src|href)="(\/_next\/static\/[^"]+)"/g), (m) => m[1]);
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(new Set(assets)).map((url) =>
|
||||||
|
cache.match(url).then((hit) => hit || fetch(url).then((r) => r.ok && cache.put(url, r)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Offline during install: the runtime handlers below fill the cache later.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(cacheShell().then(() => self.skipWaiting()));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const req = event.request;
|
||||||
|
if (req.method !== "GET") return;
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
if (url.pathname.startsWith("/api/")) return;
|
||||||
|
|
||||||
|
if (req.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(req)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE_VERSION).then((c) => c.put(url.pathname, copy));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cache = await caches.open(CACHE_VERSION);
|
||||||
|
return (await cache.match(url.pathname)) || (await cache.match("/terminal")) || Response.error();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/_next/static/")) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(CACHE_VERSION).then(async (cache) => {
|
||||||
|
const hit = await cache.match(req);
|
||||||
|
if (hit) return hit;
|
||||||
|
const res = await fetch(req);
|
||||||
|
if (res.ok) cache.put(req, res.clone());
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
|
||||||
|
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { connectDb } from "../src/lib/server/db";
|
||||||
|
import { backfillPurchaseMovements } from "../src/lib/server/stock";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off (safe to re-run): purchases recorded before the stock ledger
|
||||||
|
* existed did not add stock. This adds the missing PURCHASE movements so
|
||||||
|
* each branch's stock starts from what it actually bought.
|
||||||
|
*
|
||||||
|
* npm run backfill:stock
|
||||||
|
*
|
||||||
|
* Run it once after deploying the stock ledger, BEFORE branches record
|
||||||
|
* opening counts — a count recorded first already includes those units,
|
||||||
|
* and backfilling afterwards would add them a second time.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
await connectDb();
|
||||||
|
const added = await backfillPurchaseMovements();
|
||||||
|
console.log(`Added ${added} purchase stock movement(s).`);
|
||||||
|
await mongoose.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("backfill failed:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import { Expense } from "../src/lib/server/models/Expense";
|
|||||||
import { Purchase } from "../src/lib/server/models/Purchase";
|
import { Purchase } from "../src/lib/server/models/Purchase";
|
||||||
import { Business } from "../src/lib/server/models/Business";
|
import { Business } from "../src/lib/server/models/Business";
|
||||||
import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers";
|
import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers";
|
||||||
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
||||||
|
|
||||||
async function migrate() {
|
async function migrate() {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
@@ -75,7 +76,7 @@ async function migrate() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
migrate().catch((err) => {
|
crossTenant("one-off migration of data from before businesses existed", migrate).catch((err) => {
|
||||||
console.error("migration failed:", err.message);
|
console.error("migration failed:", err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
|
||||||
|
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { connectDb } from "../src/lib/server/db";
|
||||||
|
import { User } from "../src/lib/server/models/User";
|
||||||
|
import { platformAdminEmails } from "../src/lib/server/platform";
|
||||||
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off, safe to re-run. Moves user identity from platform-wide to
|
||||||
|
* per-business uniqueness:
|
||||||
|
*
|
||||||
|
* 1. Marks today's platform operators. Until now an operator was anyone
|
||||||
|
* whose email was in PLATFORM_ADMIN_EMAILS; emails were unique, so
|
||||||
|
* that was one account each. From now on emails are only unique per
|
||||||
|
* business, so access also needs the platformAdmin flag on the account.
|
||||||
|
* Run this BEFORE dropping the old index, while each listed email
|
||||||
|
* still matches exactly one account.
|
||||||
|
* 2. Syncs the User indexes: drops the old unique `email_1` and
|
||||||
|
* `phoneNumber_1`, creates unique (businessId, email) and
|
||||||
|
* (businessId, phoneNumber), and a plain email lookup index.
|
||||||
|
*
|
||||||
|
* npm run migrate:identity
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
await connectDb();
|
||||||
|
|
||||||
|
for (const email of platformAdminEmails()) {
|
||||||
|
const accounts = await User.find({ email }).select("_id").lean();
|
||||||
|
if (accounts.length === 1) {
|
||||||
|
await User.updateOne({ _id: accounts[0]._id }, { $set: { platformAdmin: true } });
|
||||||
|
console.log(`Platform operator: ${email}`);
|
||||||
|
} else if (accounts.length === 0) {
|
||||||
|
console.warn(`No account for ${email} - create it with npm run seed.`);
|
||||||
|
} else {
|
||||||
|
console.warn(`${email} has ${accounts.length} accounts - set platformAdmin on the right one by hand.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropped = await User.syncIndexes();
|
||||||
|
console.log(`User indexes synced. Dropped: ${dropped.length ? dropped.join(", ") : "none"}.`);
|
||||||
|
await mongoose.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
crossTenant("identity migration spans every business", main).catch((err) => {
|
||||||
|
console.error("migration failed:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+18
-2
@@ -13,6 +13,8 @@ import { connectDb } from "../src/lib/server/db";
|
|||||||
import { User } from "../src/lib/server/models/User";
|
import { User } from "../src/lib/server/models/User";
|
||||||
import { Product } from "../src/lib/server/models/Product";
|
import { Product } from "../src/lib/server/models/Product";
|
||||||
import { Business } from "../src/lib/server/models/Business";
|
import { Business } from "../src/lib/server/models/Business";
|
||||||
|
import { isPlatformAdminEmail } from "../src/lib/server/platform";
|
||||||
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run once against a fresh database: `npm run seed`.
|
* Run once against a fresh database: `npm run seed`.
|
||||||
@@ -39,10 +41,23 @@ async function seed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let business = await Business.findOne({ name: demoBusinessName });
|
let business = await Business.findOne({ name: demoBusinessName });
|
||||||
const existing = await User.findOne({ email });
|
// Emails are unique per business, so look in the seeded business (or, for
|
||||||
|
// a legacy single-tenant database, for its one super admin).
|
||||||
|
const existing = business
|
||||||
|
? await User.findOne({ email, businessId: business._id })
|
||||||
|
: await User.findOne({ email, role: "SUPER_ADMIN" });
|
||||||
|
// The seed is the only way (besides the identity migration) to make an
|
||||||
|
// operator: the flag is honoured only while the email is also listed in
|
||||||
|
// PLATFORM_ADMIN_EMAILS.
|
||||||
|
const platformAdmin = isPlatformAdminEmail(email);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`Super admin (${email}) already exists, skipping`);
|
console.log(`Super admin (${email}) already exists, skipping`);
|
||||||
|
if (platformAdmin && !existing.platformAdmin) {
|
||||||
|
existing.platformAdmin = true;
|
||||||
|
await existing.save();
|
||||||
|
console.log(`Marked ${email} as a platform operator`);
|
||||||
|
}
|
||||||
if (!business) {
|
if (!business) {
|
||||||
// Backfill a business for a legacy single-tenant admin if needed.
|
// Backfill a business for a legacy single-tenant admin if needed.
|
||||||
business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
|
business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
|
||||||
@@ -61,6 +76,7 @@ async function seed() {
|
|||||||
phoneNumber: "254700000000",
|
phoneNumber: "254700000000",
|
||||||
email,
|
email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
|
platformAdmin,
|
||||||
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
|
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
|
||||||
});
|
});
|
||||||
business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
|
business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
|
||||||
@@ -92,7 +108,7 @@ async function seed() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
seed().catch((err) => {
|
crossTenant("seeding may find a legacy admin outside any business", seed).catch((err) => {
|
||||||
console.error("seed failed:", err.message);
|
console.error("seed failed:", err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import { AuthUser } from "@/lib/types";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 text-depth-900 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25";
|
||||||
|
|
||||||
|
/** The signed-in user changes their own password. */
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [me, setMe] = useState<AuthUser | null>(null);
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<{ user: AuthUser }>("/api/auth/me")
|
||||||
|
.then((r) => setMe(r.user))
|
||||||
|
.catch(() => router.replace("/login"));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const home = me?.role === "DELIVERY" ? "/terminal" : "/admin/dashboard";
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (next.length < 8) return setError("The new password must be at least 8 characters.");
|
||||||
|
if (next !== confirm) return setError("The two new passwords do not match.");
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/auth/password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ currentPassword: current, newPassword: next }),
|
||||||
|
});
|
||||||
|
setDone(true);
|
||||||
|
setCurrent("");
|
||||||
|
setNext("");
|
||||||
|
setConfirm("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Could not change your password.");
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-5 py-10">
|
||||||
|
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-soft">
|
||||||
|
<h1 className="font-display text-2xl font-bold text-depth-900">Change password</h1>
|
||||||
|
{me && <p className="mt-1 text-sm text-depth-600">{me.email}</p>}
|
||||||
|
|
||||||
|
{done ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600">
|
||||||
|
Your password is changed. Any other phone or computer signed in to your account has been signed out.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.replace(home)}
|
||||||
|
className="tap-target mt-4 w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="current-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Current password
|
||||||
|
</label>
|
||||||
|
<input id="current-password" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<input id="new-password" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Confirm new password
|
||||||
|
</label>
|
||||||
|
<input id="confirm-password" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="tap-target w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Change password"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => router.replace(home)} className="w-full text-sm text-depth-500 underline">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+156
-27
@@ -27,6 +27,12 @@ export default function BranchesPage() {
|
|||||||
const [mgrEmail, setMgrEmail] = useState("");
|
const [mgrEmail, setMgrEmail] = useState("");
|
||||||
const [mgrPhone, setMgrPhone] = useState("");
|
const [mgrPhone, setMgrPhone] = useState("");
|
||||||
const [mgrPassword, setMgrPassword] = useState("");
|
const [mgrPassword, setMgrPassword] = useState("");
|
||||||
|
const [inviteLink, setInviteLink] = useState<{ name: string; url: string } | null>(null);
|
||||||
|
|
||||||
|
// Inline rename.
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editCity, setEditCity] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -52,7 +58,7 @@ export default function BranchesPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadBranches() {
|
async function loadBranches() {
|
||||||
const result = await apiFetch<{ branches: Branch[] }>("/api/branches");
|
const result = await apiFetch<{ branches: Branch[] }>("/api/branches?includeInactive=1");
|
||||||
setBranches(result.branches);
|
setBranches(result.branches);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,28 +77,29 @@ export default function BranchesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (withManager) {
|
if (withManager) {
|
||||||
if (!mgrName.trim() || !mgrEmail.trim() || !mgrPhone.trim() || !mgrPassword) {
|
if (!mgrName.trim() || !mgrEmail.trim() || !mgrPhone.trim()) {
|
||||||
setError("Fill in the manager's name, email, phone and password — or switch off the manager login.");
|
setError("Fill in the manager's name, email and phone — or switch off the manager login.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mgrPassword.length < 8) {
|
if (mgrPassword && mgrPassword.length < 8) {
|
||||||
setError("The manager password must be at least 8 characters.");
|
setError("The manager password must be at least 8 characters, or leave it blank to send an invite link.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
body.manager = {
|
body.manager = {
|
||||||
name: mgrName.trim(),
|
name: mgrName.trim(),
|
||||||
email: mgrEmail.trim(),
|
email: mgrEmail.trim(),
|
||||||
phoneNumber: mgrPhone.trim(),
|
phoneNumber: mgrPhone.trim(),
|
||||||
password: mgrPassword,
|
password: mgrPassword || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/branches", {
|
const result = await apiFetch<{ inviteUrl?: string | null }>("/api/branches", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
setInviteLink(result.inviteUrl ? { name: mgrName.trim(), url: result.inviteUrl } : null);
|
||||||
if (withManager) {
|
if (withManager) {
|
||||||
show(branchName.trim() + " created with manager login for " + mgrEmail.trim(), "success");
|
show(branchName.trim() + " created with manager login for " + mgrEmail.trim(), "success");
|
||||||
} else {
|
} else {
|
||||||
@@ -113,6 +120,46 @@ export default function BranchesPage() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveRename(id: string) {
|
||||||
|
if (!editName.trim() || !editCity.trim()) {
|
||||||
|
setError("Enter both a branch name and a city.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/branches/" + id, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ branchName: editName.trim(), locationCity: editCity.trim() }),
|
||||||
|
});
|
||||||
|
show("Branch updated", "success");
|
||||||
|
setEditingId(null);
|
||||||
|
await loadBranches();
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not update branch", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleBranch(b: Branch) {
|
||||||
|
const deactivating = b.isActive !== false;
|
||||||
|
if (
|
||||||
|
deactivating &&
|
||||||
|
!window.confirm(
|
||||||
|
`Deactivate ${b.branchName}? Its sales and stock history stay in your reports, but it takes no new staff, stock or sales. You can reactivate it later.`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/branches/" + b._id, { method: "PATCH", body: JSON.stringify({ isActive: !deactivating }) });
|
||||||
|
show(`${b.branchName} ${deactivating ? "deactivated" : "reactivated"}`, "success");
|
||||||
|
await loadBranches();
|
||||||
|
} catch (err) {
|
||||||
|
// e.g. stock or staff still at the branch, or the plan's branch limit.
|
||||||
|
const message = err instanceof ApiError ? err.message : "Could not update branch";
|
||||||
|
setError(message);
|
||||||
|
show(message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!me) {
|
if (!me) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-sand-50 pb-12">
|
<main className="min-h-screen bg-sand-50 pb-12">
|
||||||
@@ -150,6 +197,33 @@ export default function BranchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{inviteLink && (
|
||||||
|
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm text-depth-800">
|
||||||
|
<p className="font-medium">Send this link to {inviteLink.name} so they can set their password.</p>
|
||||||
|
<p className="mt-1 text-xs text-depth-500">It works once and expires in 7 days. You can make a new one from Staff.</p>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={inviteLink.url}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
aria-label="Invite link"
|
||||||
|
className="mt-3 w-full rounded-lg border border-depth-200 bg-white px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/?text=${encodeURIComponent(`Set your Vessel password here: ${inviteLink.url}`)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
|
||||||
|
>
|
||||||
|
Share on WhatsApp
|
||||||
|
</a>
|
||||||
|
<button onClick={() => setInviteLink(null)} className="px-2 text-xs text-depth-500 underline">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SkeletonList rows={3} />
|
<SkeletonList rows={3} />
|
||||||
) : (
|
) : (
|
||||||
@@ -175,22 +249,77 @@ export default function BranchesPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-depth-100">
|
<ul className="divide-y divide-depth-100">
|
||||||
{branches.map((b) => (
|
{branches.map((b) => {
|
||||||
<li key={b._id} className="flex items-center justify-between px-4 py-3 animate-fade-up">
|
const active = b.isActive !== false;
|
||||||
<div>
|
return (
|
||||||
<p className="font-medium text-depth-900">{b.branchName}</p>
|
<li key={b._id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
||||||
<p className="text-xs text-depth-500">{b.locationCity}</p>
|
{editingId === b._id ? (
|
||||||
</div>
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||||
<span
|
<input
|
||||||
className={
|
autoFocus
|
||||||
"rounded-full px-2.5 py-1 text-xs font-medium " +
|
value={editName}
|
||||||
(b.managerId ? "bg-confirm-500/15 text-confirm-600" : "bg-cash-500/15 text-cash-600")
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
}
|
aria-label="Branch name"
|
||||||
>
|
className="min-w-0 flex-1 rounded-lg border border-depth-200 px-2 py-1 text-sm"
|
||||||
{b.managerId ? "Has manager" : "No manager yet"}
|
/>
|
||||||
</span>
|
<input
|
||||||
</li>
|
value={editCity}
|
||||||
))}
|
onChange={(e) => setEditCity(e.target.value)}
|
||||||
|
aria-label="City"
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-depth-200 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<button onClick={() => saveRename(b._id)} className="rounded-lg bg-flow-500 px-3 py-1 text-sm font-semibold text-depth-950">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="text-sm text-depth-500 underline">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={active ? "" : "opacity-50"}>
|
||||||
|
<p className="font-medium text-depth-900">
|
||||||
|
{b.branchName}
|
||||||
|
{!active && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-depth-500">{b.locationCity}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editingId !== b._id && (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"rounded-full px-2.5 py-1 text-xs font-medium " +
|
||||||
|
(b.managerId ? "bg-confirm-500/15 text-confirm-600" : "bg-cash-500/15 text-cash-600")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{b.managerId ? "Has manager" : "No manager yet"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(b._id);
|
||||||
|
setEditName(b.branchName);
|
||||||
|
setEditCity(b.locationCity);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleBranch(b)}
|
||||||
|
className={
|
||||||
|
"rounded-lg border px-2.5 py-1 text-xs font-medium " +
|
||||||
|
(active ? "border-alert-500/40 text-alert-600" : "border-confirm-500/40 text-confirm-600")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{active ? "Deactivate" : "Reactivate"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -238,8 +367,8 @@ export default function BranchesPage() {
|
|||||||
Create this branch's manager login now
|
Create this branch's manager login now
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-xs text-depth-500">
|
<span className="block text-xs text-depth-500">
|
||||||
The manager signs in with this email & password, then adds their own delivery
|
Leave the password blank to get an invite link the manager uses to choose their own.
|
||||||
staff. You can leave this off and assign a manager later from Staff.
|
They then add their own delivery staff. You can also assign a manager later from Staff.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -285,14 +414,14 @@ export default function BranchesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-password">
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="mgr-password">
|
||||||
Temporary password
|
Password (optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="mgr-password"
|
id="mgr-password"
|
||||||
type="text"
|
type="text"
|
||||||
value={mgrPassword}
|
value={mgrPassword}
|
||||||
onChange={(e) => setMgrPassword(e.target.value)}
|
onChange={(e) => setMgrPassword(e.target.value)}
|
||||||
placeholder="At least 8 characters"
|
placeholder="Blank = send an invite link"
|
||||||
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
|
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
import { useTransactionFeed } from "@/lib/pollingFeed";
|
import { useTransactionFeed } from "@/lib/pollingFeed";
|
||||||
import { AuthUser, Branch, DashboardSummary, ReconciliationRow } from "@/lib/types";
|
import { AuthUser, Branch, DashboardSummary, ReconciliationRow, StockLevel } from "@/lib/types";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { SkeletonCardGrid, SkeletonTable } from "@/components/ui/Skeleton";
|
import { SkeletonCardGrid, SkeletonTable } from "@/components/ui/Skeleton";
|
||||||
import EmptyState from "@/components/ui/EmptyState";
|
import EmptyState from "@/components/ui/EmptyState";
|
||||||
@@ -16,6 +16,8 @@ import LiveStatsRow from "@/components/admin/LiveStatsRow";
|
|||||||
import BranchRankingList from "@/components/admin/BranchRankingList";
|
import BranchRankingList from "@/components/admin/BranchRankingList";
|
||||||
import ActivityFeedMatrix from "@/components/admin/ActivityFeedMatrix";
|
import ActivityFeedMatrix from "@/components/admin/ActivityFeedMatrix";
|
||||||
import ReconciliationGrid from "@/components/admin/ReconciliationGrid";
|
import ReconciliationGrid from "@/components/admin/ReconciliationGrid";
|
||||||
|
import StockSummaryCard from "@/components/admin/StockSummaryCard";
|
||||||
|
import AccountLinks from "@/components/AccountLinks";
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -27,6 +29,7 @@ export default function AdminDashboardPage() {
|
|||||||
const [selectedBranchId, setSelectedBranchId] = useState<string>("ALL");
|
const [selectedBranchId, setSelectedBranchId] = useState<string>("ALL");
|
||||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||||
const [reconciliation, setReconciliation] = useState<ReconciliationRow[]>([]);
|
const [reconciliation, setReconciliation] = useState<ReconciliationRow[]>([]);
|
||||||
|
const [stock, setStock] = useState<StockLevel[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const { events: feedTransactions, connected } = useTransactionFeed(selectedBranchId, 50);
|
const { events: feedTransactions, connected } = useTransactionFeed(selectedBranchId, 50);
|
||||||
@@ -75,12 +78,14 @@ export default function AdminDashboardPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const branchParam = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId;
|
const branchParam = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId;
|
||||||
const [summaryRes, reconRes] = await Promise.all([
|
const [summaryRes, reconRes, stockRes] = await Promise.all([
|
||||||
apiFetch<DashboardSummary>("/api/dashboard/summary" + branchParam),
|
apiFetch<DashboardSummary>("/api/dashboard/summary" + branchParam),
|
||||||
apiFetch<{ rows: ReconciliationRow[] }>("/api/dashboard/reconciliation" + branchParam),
|
apiFetch<{ rows: ReconciliationRow[] }>("/api/dashboard/reconciliation" + branchParam),
|
||||||
|
apiFetch<{ levels: StockLevel[] }>("/api/stock" + branchParam),
|
||||||
]);
|
]);
|
||||||
setSummary(summaryRes);
|
setSummary(summaryRes);
|
||||||
setReconciliation(reconRes.rows);
|
setReconciliation(reconRes.rows);
|
||||||
|
setStock(stockRes.levels);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
show(err instanceof ApiError ? err.message : "Could not load dashboard data", "error");
|
show(err instanceof ApiError ? err.message : "Could not load dashboard data", "error");
|
||||||
}
|
}
|
||||||
@@ -108,7 +113,8 @@ export default function AdminDashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scope: "global" | "branch" = selectedBranchId === "ALL" ? "global" : "branch";
|
const scope: "global" | "branch" = selectedBranchId === "ALL" ? "global" : "branch";
|
||||||
const showNoBranchesNudge = me.role === "SUPER_ADMIN" && branchesLoaded && branches.length === 0;
|
// The operator's own business is a placeholder; don't nudge it to set up branches.
|
||||||
|
const showNoBranchesNudge = me.role === "SUPER_ADMIN" && !me.isPlatformAdmin && branchesLoaded && branches.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-sand-50 pb-12">
|
<main className="min-h-screen bg-sand-50 pb-12">
|
||||||
@@ -125,6 +131,13 @@ export default function AdminDashboardPage() {
|
|||||||
? `${me.branchName} Dashboard`
|
? `${me.branchName} Dashboard`
|
||||||
: "Branch Dashboard"}
|
: "Branch Dashboard"}
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="mt-1 text-xs text-sand-200/60">
|
||||||
|
{me.name}
|
||||||
|
{me.isPlatformAdmin && " · Platform operator"}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<AccountLinks />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<GlobalSwitcher
|
<GlobalSwitcher
|
||||||
@@ -133,13 +146,21 @@ export default function AdminDashboardPage() {
|
|||||||
onChange={setSelectedBranchId}
|
onChange={setSelectedBranchId}
|
||||||
isSuperAdmin={me.role === "SUPER_ADMIN"}
|
isSuperAdmin={me.role === "SUPER_ADMIN"}
|
||||||
/>
|
/>
|
||||||
{me.isPlatformAdmin && (
|
{me.isPlatformAdmin && (
|
||||||
<Link href="/platform" className="tap-target flex items-center rounded-xl2 bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-depth-950 hover:opacity-90">
|
<Link href="/platform" className="tap-target flex items-center rounded-xl2 bg-gradient-to-r from-flow-500 to-flow-700 px-4 text-sm font-semibold text-depth-950 hover:opacity-90">
|
||||||
Platform
|
Platform
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<Link href="/admin/stock" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||||
|
Stock
|
||||||
|
</Link>
|
||||||
|
{me.role === "SUPER_ADMIN" && (
|
||||||
|
<Link href="/admin/branches" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||||
|
Branches
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link href="/admin/products" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
<Link href="/admin/products" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||||
Sizes
|
Products
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/admin/staff" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
<Link href="/admin/staff" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||||
Staff
|
Staff
|
||||||
@@ -183,6 +204,8 @@ export default function AdminDashboardPage() {
|
|||||||
pendingMpesa={summary.totals.pendingMpesa}
|
pendingMpesa={summary.totals.pendingMpesa}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StockSummaryCard levels={stock} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<PaymentSplitDonut cashTotal={summary.totals.cashTotal} mpesaTotal={summary.totals.mpesaTotal} />
|
<PaymentSplitDonut cashTotal={summary.totals.cashTotal} mpesaTotal={summary.totals.mpesaTotal} />
|
||||||
<LiveStatsRow
|
<LiveStatsRow
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import { AuthUser, Branch } from "@/lib/types";
|
||||||
|
import { formatSize } from "@/lib/units";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { SkeletonList } from "@/components/ui/Skeleton";
|
||||||
|
import EmptyState from "@/components/ui/EmptyState";
|
||||||
|
|
||||||
|
interface PriceRow {
|
||||||
|
productId: string;
|
||||||
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
sizeLiters: number;
|
||||||
|
isActive: boolean;
|
||||||
|
defaultPrice: number;
|
||||||
|
branchPrice: number | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PricesResponse {
|
||||||
|
branchId: string;
|
||||||
|
branchName: string;
|
||||||
|
allowed: boolean;
|
||||||
|
prices: PriceRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch prices: what each branch charges for each product. Owners edit;
|
||||||
|
* branch managers see their own branch's prices.
|
||||||
|
*/
|
||||||
|
export default function BranchPricesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { show } = useToast();
|
||||||
|
const [me, setMe] = useState<AuthUser | null>(null);
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
|
const [branchId, setBranchId] = useState("");
|
||||||
|
const [data, setData] = useState<PricesResponse | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const isOwner = me?.role === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
|
||||||
|
if (result.user.role === "DELIVERY") {
|
||||||
|
router.replace("/terminal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMe(result.user);
|
||||||
|
const br = await apiFetch<{ branches: Branch[] }>("/api/branches");
|
||||||
|
setBranches(br.branches);
|
||||||
|
const first = result.user.role === "SUPER_ADMIN" ? br.branches[0]?._id : result.user.branchId;
|
||||||
|
if (first) setBranchId(first);
|
||||||
|
} catch {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!branchId) return;
|
||||||
|
loadPrices(branchId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [branchId]);
|
||||||
|
|
||||||
|
async function loadPrices(id: string) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<PricesResponse>("/api/branch-prices?branchId=" + id);
|
||||||
|
setData(res);
|
||||||
|
setDrafts(Object.fromEntries(res.prices.map((p) => [p.productId, String(p.branchPrice ?? p.defaultPrice)])));
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not load prices", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(row: PriceRow) {
|
||||||
|
const price = parseFloat(drafts[row.productId]);
|
||||||
|
if (!Number.isFinite(price) || price <= 0) {
|
||||||
|
show("Enter a price above zero", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingId(row.productId);
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/branch-prices", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ branchId, productId: row.productId, unitPrice: price }),
|
||||||
|
});
|
||||||
|
show(
|
||||||
|
price === row.defaultPrice
|
||||||
|
? `${row.label} back to the default price`
|
||||||
|
: `${row.label} now KES ${price.toLocaleString()} at ${data?.branchName}`,
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
await loadPrices(branchId);
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not save the price", "error");
|
||||||
|
}
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset(row: PriceRow) {
|
||||||
|
setSavingId(row.productId);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/branch-prices?branchId=${branchId}&productId=${row.productId}`, { method: "DELETE" });
|
||||||
|
show(`${row.label} back to the default price`, "success");
|
||||||
|
await loadPrices(branchId);
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not reset the price", "error");
|
||||||
|
}
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !me) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-sand-50 pb-12">
|
||||||
|
<header className="bg-depth-900 px-5 pb-8 pt-8">
|
||||||
|
<div className="mx-auto h-14 max-w-3xl" />
|
||||||
|
</header>
|
||||||
|
<div className="mx-auto -mt-4 max-w-3xl px-5">
|
||||||
|
<SkeletonList rows={4} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canEdit = isOwner && !!data?.allowed;
|
||||||
|
const overrides = data?.prices.filter((p) => p.branchPrice !== null).length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-sand-50 pb-12">
|
||||||
|
<header className="bg-depth-900 px-5 pb-8 pt-8 text-white">
|
||||||
|
<div className="mx-auto flex max-w-3xl flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-flow-400">{me.businessName ?? "Vessel"}</p>
|
||||||
|
<h1 className="font-display text-xl font-bold">Branch prices</h1>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/products"
|
||||||
|
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
|
||||||
|
>
|
||||||
|
← Products
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mx-auto -mt-4 max-w-3xl space-y-5 px-5">
|
||||||
|
{branches.length === 0 ? (
|
||||||
|
<div className="rounded-xl2 bg-white shadow-soft">
|
||||||
|
<EmptyState icon="🏢" title="No branches yet" description="Create a branch first, then set its prices here." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl2 bg-white shadow-soft">
|
||||||
|
<div className="space-y-3 border-b border-depth-100 px-4 py-3">
|
||||||
|
{isOwner ? (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price-branch">
|
||||||
|
Branch
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="price-branch"
|
||||||
|
value={branchId}
|
||||||
|
onChange={(e) => setBranchId(e.target.value)}
|
||||||
|
className="tap-target w-full rounded-xl border border-depth-200 bg-white px-3 sm:w-72"
|
||||||
|
>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b._id} value={b._id}>
|
||||||
|
{b.branchName} — {b.locationCity}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h2 className="font-display font-semibold text-depth-900">{data?.branchName ?? "Your branch"}</h2>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-depth-500">
|
||||||
|
Each product sells at its default price unless this branch has its own price. Cashiers at the branch
|
||||||
|
see the new price the next time their till loads products.
|
||||||
|
{!isOwner && " Only the business owner can change prices."}
|
||||||
|
</p>
|
||||||
|
{isOwner && data && !data.allowed && (
|
||||||
|
<p className="rounded-lg bg-cash-500/10 px-3 py-2 text-sm text-cash-700">
|
||||||
|
Branch prices are part of the Growth plan. Existing branch prices still apply and can be reset to
|
||||||
|
the default.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data && <p className="text-xs font-medium text-depth-600">{overrides} product(s) priced differently here</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!data ? (
|
||||||
|
<SkeletonList rows={3} />
|
||||||
|
) : data.prices.length === 0 ? (
|
||||||
|
<EmptyState icon="🧴" title="No products yet" description="Add products first on the Products page." />
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-depth-100">
|
||||||
|
{data.prices.map((row) => {
|
||||||
|
const custom = row.branchPrice !== null;
|
||||||
|
const draft = drafts[row.productId] ?? "";
|
||||||
|
const changed = parseFloat(draft) !== (row.branchPrice ?? row.defaultPrice);
|
||||||
|
return (
|
||||||
|
<li key={row.productId} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-depth-900">
|
||||||
|
{row.label}{" "}
|
||||||
|
<span className="text-depth-500">· {formatSize(row.sizeLiters, row.unit)}</span>
|
||||||
|
{!row.isActive && <span className="ml-2 text-xs text-depth-500">(deactivated)</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-depth-500">
|
||||||
|
Default KES {row.defaultPrice.toLocaleString()}
|
||||||
|
{custom && (
|
||||||
|
<span className="ml-2 rounded-full bg-flow-500/15 px-2 py-0.5 font-medium text-flow-700">
|
||||||
|
This branch: KES {row.branchPrice!.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEdit ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-depth-500">KES</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDrafts((d) => ({ ...d, [row.productId]: e.target.value }))}
|
||||||
|
aria-label={`Price of ${row.label} at this branch`}
|
||||||
|
className="w-24 rounded-lg border border-depth-200 px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => save(row)}
|
||||||
|
disabled={!changed || savingId === row.productId}
|
||||||
|
className="rounded-lg bg-flow-500 px-3 py-1.5 text-sm font-semibold text-depth-950 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{custom && (
|
||||||
|
<button
|
||||||
|
onClick={() => reset(row)}
|
||||||
|
disabled={savingId === row.productId}
|
||||||
|
className="text-xs text-depth-500 underline"
|
||||||
|
>
|
||||||
|
Use default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm font-semibold text-depth-900">
|
||||||
|
KES {(row.branchPrice ?? row.defaultPrice).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{isOwner && custom && (
|
||||||
|
<button onClick={() => reset(row)} className="text-xs text-depth-500 underline">
|
||||||
|
Use default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+112
-29
@@ -4,11 +4,11 @@ import { useEffect, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
import { AuthUser, Product } from "@/lib/types";
|
import { AuthUser, Product, StockLevel } from "@/lib/types";
|
||||||
|
import { PRODUCT_UNITS, UNIT_LABELS, formatSize, type ProductUnit } from "@/lib/units";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { SkeletonList } from "@/components/ui/Skeleton";
|
import { SkeletonList } from "@/components/ui/Skeleton";
|
||||||
import EmptyState from "@/components/ui/EmptyState";
|
import EmptyState from "@/components/ui/EmptyState";
|
||||||
import ConfirmButton from "@/components/ui/ConfirmButton";
|
|
||||||
|
|
||||||
export default function ProductsPage() {
|
export default function ProductsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,10 +21,16 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
const [sizeLiters, setSizeLiters] = useState("");
|
const [sizeLiters, setSizeLiters] = useState("");
|
||||||
|
const [unit, setUnit] = useState<ProductUnit>("L");
|
||||||
const [unitPrice, setUnitPrice] = useState("");
|
const [unitPrice, setUnitPrice] = useState("");
|
||||||
|
const [showInactive, setShowInactive] = useState(false);
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editPrice, setEditPrice] = useState("");
|
const [editPrice, setEditPrice] = useState("");
|
||||||
|
const [editThreshold, setEditThreshold] = useState("");
|
||||||
|
|
||||||
|
// Products and prices apply to every branch, so only the owner edits them.
|
||||||
|
const canEdit = me?.role === "SUPER_ADMIN";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -46,7 +52,7 @@ export default function ProductsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function loadProducts() {
|
async function loadProducts() {
|
||||||
const result = await apiFetch<{ products: Product[] }>("/api/products");
|
const result = await apiFetch<{ products: Product[] }>("/api/products?includeInactive=1");
|
||||||
setProducts(result.products);
|
setProducts(result.products);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +62,8 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
const size = parseFloat(sizeLiters);
|
const size = parseFloat(sizeLiters);
|
||||||
const price = parseFloat(unitPrice);
|
const price = parseFloat(unitPrice);
|
||||||
if (!label.trim() || !Number.isFinite(size) || size <= 0 || !Number.isFinite(price) || price < 0) {
|
if (!label.trim() || !Number.isFinite(size) || size <= 0 || !Number.isFinite(price) || price <= 0) {
|
||||||
setError("Enter a valid label, size, and price.");
|
setError("Enter a label, a size above zero and a price above zero.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +71,7 @@ export default function ProductsPage() {
|
|||||||
try {
|
try {
|
||||||
await apiFetch("/api/products", {
|
await apiFetch("/api/products", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ label: label.trim(), sizeLiters: size, unitPrice: price, sortOrder: products.length }),
|
body: JSON.stringify({ label: label.trim(), sizeLiters: size, unit, unitPrice: price, sortOrder: products.length }),
|
||||||
});
|
});
|
||||||
show(label.trim() + " added to the catalog", "success");
|
show(label.trim() + " added to the catalog", "success");
|
||||||
setLabel("");
|
setLabel("");
|
||||||
@@ -82,14 +88,22 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
async function handlePriceUpdate(id: string) {
|
async function handlePriceUpdate(id: string) {
|
||||||
const price = parseFloat(editPrice);
|
const price = parseFloat(editPrice);
|
||||||
if (!Number.isFinite(price) || price < 0) {
|
const threshold = parseInt(editThreshold, 10);
|
||||||
setError("Enter a valid price.");
|
if (!Number.isFinite(price) || price <= 0) {
|
||||||
|
setError("Enter a price above zero.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(threshold) || threshold < 0) {
|
||||||
|
setError("Enter a whole number for the low-stock alert.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/products/" + id, { method: "PATCH", body: JSON.stringify({ unitPrice: price }) });
|
await apiFetch("/api/products/" + id, {
|
||||||
show("Price updated", "success");
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ unitPrice: price, lowStockThreshold: threshold }),
|
||||||
|
});
|
||||||
|
show("Product updated", "success");
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
await loadProducts();
|
await loadProducts();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -101,6 +115,24 @@ export default function ProductsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleActive(product: Product) {
|
async function handleToggleActive(product: Product) {
|
||||||
|
if (product.isActive) {
|
||||||
|
// Tell the owner what happens to stock still on hand before it goes.
|
||||||
|
let held: StockLevel[] = [];
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ levels: StockLevel[] }>("/api/stock");
|
||||||
|
held = res.levels.filter((l) => l.productId === product._id && l.quantity !== 0);
|
||||||
|
} catch {
|
||||||
|
// Offline or failed: still ask for confirmation below.
|
||||||
|
}
|
||||||
|
const stockNote =
|
||||||
|
held.length > 0
|
||||||
|
? `\n\nStill in stock: ${held.map((l) => `${l.branchName} ${l.quantity}`).join(", ")}. ` +
|
||||||
|
"It stays on the Stock page, where it can be transferred or counted out, but cashiers can no longer sell it."
|
||||||
|
: "";
|
||||||
|
if (!window.confirm(`Deactivate ${product.label}? It disappears from the till and new purchases. Past sales and purchases are kept.${stockNote}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/products/" + product._id, {
|
await apiFetch("/api/products/" + product._id, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -133,11 +165,16 @@ export default function ProductsPage() {
|
|||||||
<div className="mx-auto flex max-w-3xl items-center justify-between">
|
<div className="mx-auto flex max-w-3xl items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
|
<p className="text-xs uppercase tracking-wide text-flow-400">Vessel</p>
|
||||||
<h1 className="font-display text-xl font-bold">Jerrycan sizes & pricing</h1>
|
<h1 className="font-display text-xl font-bold">Products & pricing</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link href="/admin/prices" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||||
|
Branch prices
|
||||||
|
</Link>
|
||||||
<Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
<Link href="/admin/dashboard" className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15">
|
||||||
← Dashboard
|
← Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -152,7 +189,24 @@ export default function ProductsPage() {
|
|||||||
<p className="text-xs text-depth-500">
|
<p className="text-xs text-depth-500">
|
||||||
Sizes shown here appear as options on every branch's terminal. Deactivating a size hides
|
Sizes shown here appear as options on every branch's terminal. Deactivating a size hides
|
||||||
it from new sales without touching past transactions.
|
it from new sales without touching past transactions.
|
||||||
|
{!canEdit && " Only the business owner can change products and prices."} Prices here are the
|
||||||
|
defaults; a branch can charge its own price under{" "}
|
||||||
|
<Link href="/admin/prices" className="underline underline-offset-2">
|
||||||
|
Branch prices
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
|
{products.some((p) => !p.isActive) && (
|
||||||
|
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-depth-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showInactive}
|
||||||
|
onChange={(e) => setShowInactive(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-depth-300"
|
||||||
|
/>
|
||||||
|
Show deactivated products ({products.filter((p) => !p.isActive).length})
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
@@ -164,14 +218,18 @@ export default function ProductsPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-depth-100">
|
<ul className="divide-y divide-depth-100">
|
||||||
{products.map((p) => (
|
{products.filter((p) => showInactive || p.isActive).map((p) => (
|
||||||
<li key={p._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
<li key={p._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-depth-900">
|
<p className="font-medium text-depth-900">
|
||||||
{p.label} <span className="text-depth-500">· {p.sizeLiters}L</span>
|
{p.label} <span className="text-depth-500">· {formatSize(p.sizeLiters, p.unit)}</span>
|
||||||
</p>
|
</p>
|
||||||
{editingId === p._id ? (
|
{!canEdit ? (
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<p className="mt-0.5 font-mono text-sm text-depth-600">
|
||||||
|
KES {p.unitPrice.toLocaleString()} · low stock at {p.lowStockThreshold ?? 10}
|
||||||
|
</p>
|
||||||
|
) : editingId === p._id ? (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm text-depth-500">KES</span>
|
<span className="text-sm text-depth-500">KES</span>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -180,8 +238,19 @@ export default function ProductsPage() {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
value={editPrice}
|
value={editPrice}
|
||||||
onChange={(e) => setEditPrice(e.target.value)}
|
onChange={(e) => setEditPrice(e.target.value)}
|
||||||
|
aria-label="Price"
|
||||||
className="w-24 rounded-lg border border-depth-200 px-2 py-1 text-sm"
|
className="w-24 rounded-lg border border-depth-200 px-2 py-1 text-sm"
|
||||||
/>
|
/>
|
||||||
|
<span className="text-sm text-depth-500">low stock at</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={editThreshold}
|
||||||
|
onChange={(e) => setEditThreshold(e.target.value)}
|
||||||
|
aria-label="Low-stock alert level"
|
||||||
|
className="w-20 rounded-lg border border-depth-200 px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
<button onClick={() => handlePriceUpdate(p._id)} disabled={saving} className="rounded-lg bg-flow-500 px-3 py-1 text-sm font-semibold text-depth-950">
|
<button onClick={() => handlePriceUpdate(p._id)} disabled={saving} className="rounded-lg bg-flow-500 px-3 py-1 text-sm font-semibold text-depth-950">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -194,10 +263,11 @@ export default function ProductsPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingId(p._id);
|
setEditingId(p._id);
|
||||||
setEditPrice(String(p.unitPrice));
|
setEditPrice(String(p.unitPrice));
|
||||||
|
setEditThreshold(String(p.lowStockThreshold ?? 10));
|
||||||
}}
|
}}
|
||||||
className="mt-0.5 font-mono text-sm text-flow-600 underline underline-offset-2"
|
className="mt-0.5 font-mono text-sm text-flow-600 underline underline-offset-2"
|
||||||
>
|
>
|
||||||
KES {p.unitPrice.toLocaleString()} · edit
|
KES {p.unitPrice.toLocaleString()} · low stock at {p.lowStockThreshold ?? 10} · edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -205,14 +275,13 @@ export default function ProductsPage() {
|
|||||||
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + (p.isActive ? "bg-confirm-500/15 text-confirm-600" : "bg-depth-100 text-depth-500")}>
|
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + (p.isActive ? "bg-confirm-500/15 text-confirm-600" : "bg-depth-100 text-depth-500")}>
|
||||||
{p.isActive ? "Active" : "Inactive"}
|
{p.isActive ? "Active" : "Inactive"}
|
||||||
</span>
|
</span>
|
||||||
{p.isActive ? (
|
{!canEdit ? null : p.isActive ? (
|
||||||
<ConfirmButton
|
<button
|
||||||
label="Deactivate"
|
onClick={() => handleToggleActive(p)}
|
||||||
confirmLabel="Deactivate"
|
className="rounded-lg border border-alert-500/40 px-3 py-1.5 text-sm font-medium text-alert-600"
|
||||||
variant="danger"
|
>
|
||||||
onConfirm={() => handleToggleActive(p)}
|
Deactivate
|
||||||
className="rounded-lg border border-depth-200 px-3 py-1.5 text-sm font-medium text-depth-700"
|
</button>
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleActive(p)}
|
onClick={() => handleToggleActive(p)}
|
||||||
@@ -228,9 +297,10 @@ export default function ProductsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
<form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft">
|
<form onSubmit={handleAdd} className="rounded-xl2 bg-white p-4 shadow-soft">
|
||||||
<h2 className="mb-3 font-display font-semibold text-depth-900">Add a new size</h2>
|
<h2 className="mb-3 font-display font-semibold text-depth-900">Add a product</h2>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="label">
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="label">
|
||||||
Label
|
Label
|
||||||
@@ -239,21 +309,34 @@ export default function ProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="size">
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="size">
|
||||||
Size (litres)
|
Size
|
||||||
</label>
|
</label>
|
||||||
<input id="size" type="number" min="0" step="0.1" value={sizeLiters} onChange={(e) => setSizeLiters(e.target.value)} placeholder="10" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
<input id="size" type="number" min="0" step="0.1" value={sizeLiters} onChange={(e) => setSizeLiters(e.target.value)} placeholder="10" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="unit">
|
||||||
|
Unit
|
||||||
|
</label>
|
||||||
|
<select id="unit" value={unit} onChange={(e) => setUnit(e.target.value as ProductUnit)} className="tap-target w-full rounded-xl border border-depth-200 px-3">
|
||||||
|
{PRODUCT_UNITS.map((u) => (
|
||||||
|
<option key={u} value={u}>
|
||||||
|
{UNIT_LABELS[u]} ({u})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price">
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="price">
|
||||||
Price (KES)
|
Price (KES)
|
||||||
</label>
|
</label>
|
||||||
<input id="price" type="number" min="0" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} placeholder="110" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
<input id="price" type="number" min="0.01" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} placeholder="110" className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={saving} className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8">
|
<button type="submit" disabled={saving} className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8">
|
||||||
{saving ? "Adding…" : "Add size"}
|
{saving ? "Adding…" : "Add product"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,16 +15,23 @@ interface ProfitReport {
|
|||||||
cashTotal: number;
|
cashTotal: number;
|
||||||
mpesaTotal: number;
|
mpesaTotal: number;
|
||||||
salesCount: number;
|
salesCount: number;
|
||||||
costOfGoods: number;
|
unitsSold: number;
|
||||||
purchaseCount: number;
|
costOfGoodsSold: number;
|
||||||
|
unitsWithoutCost: number;
|
||||||
|
stockLosses: number;
|
||||||
grossProfit: number;
|
grossProfit: number;
|
||||||
expenses: number;
|
expenses: number;
|
||||||
expenseCount: number;
|
expenseCount: number;
|
||||||
netProfit: number;
|
netProfit: number;
|
||||||
|
stockPurchases: number;
|
||||||
|
purchaseCount: number;
|
||||||
|
cashSurplus: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD in the device's own calendar (the owner's business day). */
|
||||||
function isoDay(d: Date): string {
|
function isoDay(d: Date): string {
|
||||||
return d.toISOString().slice(0, 10);
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
@@ -35,6 +42,10 @@ export default function ReportsPage() {
|
|||||||
const [report, setReport] = useState<ProfitReport | null>(null);
|
const [report, setReport] = useState<ProfitReport | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const [rangeError, setRangeError] = useState<string | null>(null);
|
||||||
|
// The period the cards currently show, so a failed or invalid run can
|
||||||
|
// never be mistaken for the new period.
|
||||||
|
const [shown, setShown] = useState<{ from: string; to: string } | null>(null);
|
||||||
|
|
||||||
const monthStart = new Date();
|
const monthStart = new Date();
|
||||||
monthStart.setDate(1);
|
monthStart.setDate(1);
|
||||||
@@ -75,22 +86,27 @@ export default function ReportsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function runReport(forcedBranch?: string) {
|
async function runReport(forcedBranch?: string) {
|
||||||
|
if (!from || !to) {
|
||||||
|
setRangeError("Choose both a start and an end date.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (from > to) {
|
||||||
|
setRangeError("The start date must be on or before the end date.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRangeError(null);
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
try {
|
try {
|
||||||
const b = forcedBranch ?? branchId;
|
const b = forcedBranch ?? branchId;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams({ fromDate: from, toDate: to });
|
||||||
if (b && b !== "ALL") params.set("branchId", b);
|
if (b && b !== "ALL") params.set("branchId", b);
|
||||||
if (from) params.set("from", new Date(from).toISOString());
|
|
||||||
// include the whole "to" day
|
|
||||||
if (to) {
|
|
||||||
const end = new Date(to);
|
|
||||||
end.setHours(23, 59, 59, 999);
|
|
||||||
params.set("to", end.toISOString());
|
|
||||||
}
|
|
||||||
const result = await apiFetch<ProfitReport>("/api/reports/profit?" + params.toString());
|
const result = await apiFetch<ProfitReport>("/api/reports/profit?" + params.toString());
|
||||||
setReport(result);
|
setReport(result);
|
||||||
|
setShown({ from, to });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
show(err instanceof ApiError ? err.message : "Could not load report", "error");
|
const message = err instanceof ApiError ? err.message : "Could not load report";
|
||||||
|
setRangeError(message);
|
||||||
|
show(message, "error");
|
||||||
}
|
}
|
||||||
setFetching(false);
|
setFetching(false);
|
||||||
}
|
}
|
||||||
@@ -99,23 +115,28 @@ export default function ReportsPage() {
|
|||||||
if (!report) return;
|
if (!report) return;
|
||||||
const rows: [string, string | number][] = [
|
const rows: [string, string | number][] = [
|
||||||
["Metric", "Value (KES)"],
|
["Metric", "Value (KES)"],
|
||||||
["Range from", report.range.from.slice(0, 10)],
|
["From", shown?.from ?? ""],
|
||||||
["Range to", report.range.to.slice(0, 10)],
|
["To", shown?.to ?? ""],
|
||||||
["Revenue", report.revenue],
|
["Revenue", report.revenue],
|
||||||
[" Cash", report.cashTotal],
|
[" Cash", report.cashTotal],
|
||||||
[" M-Pesa", report.mpesaTotal],
|
[" M-Pesa", report.mpesaTotal],
|
||||||
["Sales count", report.salesCount],
|
["Sales count", report.salesCount],
|
||||||
["Cost of goods", report.costOfGoods],
|
["Units sold", report.unitsSold],
|
||||||
|
["Cost of goods sold (FIFO per branch)", report.costOfGoodsSold],
|
||||||
|
["Units sold with no purchase cost on record", report.unitsWithoutCost],
|
||||||
|
["Stock losses (counts and write-offs)", report.stockLosses],
|
||||||
["Gross profit", report.grossProfit],
|
["Gross profit", report.grossProfit],
|
||||||
["Expenses", report.expenses],
|
["Expenses", report.expenses],
|
||||||
["Estimated net profit", report.netProfit],
|
["Estimated net profit", report.netProfit],
|
||||||
|
["Stock purchased in period (cash spent)", report.stockPurchases],
|
||||||
|
["Cash surplus (sales - stock purchased - expenses)", report.cashSurplus],
|
||||||
];
|
];
|
||||||
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
|
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `vessel-profit-${report.range.from.slice(0, 10)}_${report.range.to.slice(0, 10)}.csv`;
|
a.download = `vessel-profit-${shown?.from}_${shown?.to}.csv`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
@@ -179,6 +200,7 @@ export default function ReportsPage() {
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={from}
|
value={from}
|
||||||
|
max={to || undefined}
|
||||||
onChange={(e) => setFrom(e.target.value)}
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
className="tap-target w-full rounded-xl border border-depth-200 px-3"
|
className="tap-target w-full rounded-xl border border-depth-200 px-3"
|
||||||
/>
|
/>
|
||||||
@@ -188,6 +210,7 @@ export default function ReportsPage() {
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={to}
|
value={to}
|
||||||
|
min={from || undefined}
|
||||||
onChange={(e) => setTo(e.target.value)}
|
onChange={(e) => setTo(e.target.value)}
|
||||||
className="tap-target w-full rounded-xl border border-depth-200 px-3"
|
className="tap-target w-full rounded-xl border border-depth-200 px-3"
|
||||||
/>
|
/>
|
||||||
@@ -202,8 +225,16 @@ export default function ReportsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{rangeError && <p className="mt-3 text-sm text-alert-600">{rangeError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{report && shown && (
|
||||||
|
<p className="text-sm text-depth-600">
|
||||||
|
Showing {shown.from === shown.to ? shown.from : `${shown.from} to ${shown.to}`}
|
||||||
|
{rangeError ? " (the dates above were not run)" : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{report && (
|
{report && (
|
||||||
<>
|
<>
|
||||||
{/* Profit cards */}
|
{/* Profit cards */}
|
||||||
@@ -216,14 +247,17 @@ export default function ReportsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods</p>
|
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods sold</p>
|
||||||
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.costOfGoods)}</p>
|
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.costOfGoodsSold)}</p>
|
||||||
<p className="mt-1 text-xs text-depth-500">{report.purchaseCount} purchases in range</p>
|
<p className="mt-1 text-xs text-depth-500">
|
||||||
|
{report.unitsSold} units sold, each at what its branch paid for its oldest stock (first in, first out)
|
||||||
|
{report.unitsWithoutCost > 0 && ` · ${report.unitsWithoutCost} units have no purchase cost on record`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
<p className="text-xs uppercase tracking-wide text-depth-500">Gross profit</p>
|
<p className="text-xs uppercase tracking-wide text-depth-500">Gross profit</p>
|
||||||
<p className="mt-1 font-display text-2xl font-bold text-flow-700">{money(report.grossProfit)}</p>
|
<p className="mt-1 font-display text-2xl font-bold text-flow-700">{money(report.grossProfit)}</p>
|
||||||
<p className="mt-1 text-xs text-depth-500">Revenue − cost of goods</p>
|
<p className="mt-1 text-xs text-depth-500">Revenue − cost of goods sold</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
<p className="text-xs uppercase tracking-wide text-depth-500">Expenses</p>
|
<p className="text-xs uppercase tracking-wide text-depth-500">Expenses</p>
|
||||||
@@ -240,7 +274,32 @@ export default function ReportsPage() {
|
|||||||
>
|
>
|
||||||
{money(report.netProfit)}
|
{money(report.netProfit)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-depth-500">Gross profit − expenses</p>
|
<p className="mt-1 text-xs text-depth-500">Gross profit − expenses − stock losses</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-depth-500">Stock purchased</p>
|
||||||
|
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.stockPurchases)}</p>
|
||||||
|
<p className="mt-1 text-xs text-depth-500">
|
||||||
|
Cash spent on stock in this period ({report.purchaseCount} purchases). Unsold stock is not a cost
|
||||||
|
until it is sold.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-depth-500">Cash surplus</p>
|
||||||
|
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.cashSurplus)}</p>
|
||||||
|
<p className="mt-1 text-xs text-depth-500">Sales − stock purchased − expenses</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-depth-500">Stock losses</p>
|
||||||
|
<p className={"mt-1 font-display text-2xl font-bold " + (report.stockLosses > 0 ? "text-alert-600" : "text-depth-900")}>
|
||||||
|
{money(report.stockLosses)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-depth-500">
|
||||||
|
Cost of stock written off by counts, breakage or adjustments. Deducted in net profit.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+180
-14
@@ -17,6 +17,7 @@ interface StaffMember {
|
|||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
email: string;
|
email: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
invitePending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleBadgeStyles: Record<UserRole, string> = {
|
const roleBadgeStyles: Record<UserRole, string> = {
|
||||||
@@ -42,6 +43,10 @@ export default function StaffPage() {
|
|||||||
const [phoneNumber, setPhoneNumber] = useState("");
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
// Inviting is the default: the person sets their own password, so the
|
||||||
|
// owner never has to know or pass on anyone's password.
|
||||||
|
const [setPasswordNow, setSetPasswordNow] = useState(false);
|
||||||
|
const [inviteLink, setInviteLink] = useState<{ name: string; url: string; purpose: "invite" | "reset" } | null>(null);
|
||||||
|
|
||||||
const isSuperAdmin = me?.role === "SUPER_ADMIN";
|
const isSuperAdmin = me?.role === "SUPER_ADMIN";
|
||||||
|
|
||||||
@@ -75,14 +80,14 @@ export default function StaffPage() {
|
|||||||
if (!name.trim() || !phoneNumber.trim()) return "Name and phone number are required.";
|
if (!name.trim() || !phoneNumber.trim()) return "Name and phone number are required.";
|
||||||
if (role !== "SUPER_ADMIN" && isSuperAdmin && !branchId) return "Choose which branch this account belongs to.";
|
if (role !== "SUPER_ADMIN" && isSuperAdmin && !branchId) return "Choose which branch this account belongs to.";
|
||||||
if (!email.trim()) return "Email is required — every account signs in with email and password.";
|
if (!email.trim()) return "Email is required — every account signs in with email and password.";
|
||||||
if (password.length < 8) return "Password must be at least 8 characters.";
|
if (setPasswordNow && password.length < 8) return "Password must be at least 8 characters.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiFetch("/api/users", {
|
const result = await apiFetch<{ inviteUrl: string | null }>("/api/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@@ -90,10 +95,11 @@ export default function StaffPage() {
|
|||||||
branchId: role !== "SUPER_ADMIN" && isSuperAdmin ? branchId : undefined,
|
branchId: role !== "SUPER_ADMIN" && isSuperAdmin ? branchId : undefined,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
email,
|
email,
|
||||||
password,
|
password: setPasswordNow ? password : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
show(name.trim() + " added as " + role.replace("_", " ").toLowerCase(), "success");
|
show(name.trim() + " added as " + role.replace("_", " ").toLowerCase(), "success");
|
||||||
|
setInviteLink(result.inviteUrl ? { name: name.trim(), url: result.inviteUrl, purpose: "invite" } : null);
|
||||||
setName("");
|
setName("");
|
||||||
setBranchId("");
|
setBranchId("");
|
||||||
setPhoneNumber("");
|
setPhoneNumber("");
|
||||||
@@ -109,6 +115,68 @@ export default function StaffPage() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canManage(s: StaffMember): boolean {
|
||||||
|
if (!me || s._id === me.id) return false;
|
||||||
|
if (isSuperAdmin) return true;
|
||||||
|
return s.role === "DELIVERY" && s.branchId === me.branchId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new invite link while the person has no password yet; a password
|
||||||
|
// reset link once they have one.
|
||||||
|
async function newPasswordLink(s: StaffMember) {
|
||||||
|
if (!s.invitePending && !window.confirm(`Create a password reset link for ${s.name}? Their current password keeps working until they use it.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ inviteUrl: string; purpose: "invite" | "reset" }>("/api/users/" + s._id + "/invite", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
setInviteLink({ name: s.name, url: result.inviteUrl, purpose: result.purpose });
|
||||||
|
show(result.purpose === "reset" ? "Reset link created" : "New invite link created. The old link no longer works.", "success");
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not create a link", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner only: move a manager or cashier to another branch. They are signed
|
||||||
|
// out and sign in again; sales already recorded stay with the old branch.
|
||||||
|
async function moveToBranch(s: StaffMember, branchId: string) {
|
||||||
|
const to = branches.find((b) => b._id === branchId);
|
||||||
|
if (!to || branchId === s.branchId) return;
|
||||||
|
if (!window.confirm(`Move ${s.name} to ${to.branchName}? They will be signed out and must sign in again.`)) return;
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/users/" + s._id, { method: "PATCH", body: JSON.stringify({ branchId }) });
|
||||||
|
show(`${s.name} moved to ${to.branchName}`, "success");
|
||||||
|
await loadStaff();
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not move this person", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyInvite() {
|
||||||
|
if (!inviteLink) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(inviteLink.url);
|
||||||
|
show("Invite link copied", "success");
|
||||||
|
} catch {
|
||||||
|
show("Could not copy - select the link and copy it manually", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(s: StaffMember) {
|
||||||
|
const next = !s.isActive;
|
||||||
|
if (!next && !window.confirm(`Deactivate ${s.name}? They will be signed out of every device and cannot sign in again until reactivated.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/users/" + s._id, { method: "PATCH", body: JSON.stringify({ isActive: next }) });
|
||||||
|
show(`${s.name} ${next ? "reactivated" : "deactivated"}`, "success");
|
||||||
|
await loadStaff();
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not update this account", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -171,6 +239,43 @@ export default function StaffPage() {
|
|||||||
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">{error}</div>
|
<div className="rounded-xl2 border border-alert-500/30 bg-alert-500/10 px-4 py-3 text-sm text-alert-600">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{inviteLink && (
|
||||||
|
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm text-depth-800">
|
||||||
|
<p className="font-medium">
|
||||||
|
{inviteLink.purpose === "reset"
|
||||||
|
? `Send this password reset link to ${inviteLink.name}.`
|
||||||
|
: `Send this link to ${inviteLink.name} so they can set their password.`}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-depth-500">
|
||||||
|
It works once and expires in {inviteLink.purpose === "reset" ? "24 hours" : "7 days"}. Anyone with the link can
|
||||||
|
take over the account, so send it only to them.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={inviteLink.url}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
aria-label="Invite link"
|
||||||
|
className="mt-3 w-full rounded-lg border border-depth-200 bg-white px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button onClick={copyInvite} className="rounded-lg bg-flow-500 px-3 py-1.5 text-xs font-semibold text-depth-950">
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`https://wa.me/?text=${encodeURIComponent(`${inviteLink.purpose === "reset" ? "Reset" : "Set"} your Vessel password here: ${inviteLink.url}`)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
|
||||||
|
>
|
||||||
|
Share on WhatsApp
|
||||||
|
</a>
|
||||||
|
<button onClick={() => setInviteLink(null)} className="px-2 text-xs text-depth-500 underline">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded-xl2 bg-white shadow-soft">
|
<div className="rounded-xl2 bg-white shadow-soft">
|
||||||
<div className="border-b border-depth-100 px-4 py-3">
|
<div className="border-b border-depth-100 px-4 py-3">
|
||||||
<h2 className="font-display font-semibold text-depth-900">Team</h2>
|
<h2 className="font-display font-semibold text-depth-900">Team</h2>
|
||||||
@@ -186,17 +291,61 @@ export default function StaffPage() {
|
|||||||
{staff.map((s) => {
|
{staff.map((s) => {
|
||||||
const branchName = branches.find((b) => b._id === s.branchId)?.branchName;
|
const branchName = branches.find((b) => b._id === s.branchId)?.branchName;
|
||||||
return (
|
return (
|
||||||
<li key={s._id} className="flex items-center justify-between px-4 py-3 animate-fade-up">
|
<li key={s._id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
||||||
<div>
|
<div className={s.isActive ? "" : "opacity-50"}>
|
||||||
<p className="font-medium text-depth-900">{s.name}</p>
|
<p className="font-medium text-depth-900">
|
||||||
|
{s.name}
|
||||||
|
{!s.isActive && <span className="ml-2 text-xs font-normal text-alert-600">Deactivated</span>}
|
||||||
|
{s.isActive && s.invitePending && (
|
||||||
|
<span className="ml-2 text-xs font-normal text-cash-700">Invite pending</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-depth-500">
|
<p className="text-xs text-depth-500">
|
||||||
{s.email}
|
{s.email}
|
||||||
{branchName ? " · " + branchName : ""}
|
{branchName ? " · " + branchName : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{s.role.replace("_", " ")}
|
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
|
||||||
</span>
|
{s.role.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
{isSuperAdmin && canManage(s) && s.isActive && s.role !== "SUPER_ADMIN" && branches.length > 1 && (
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => moveToBranch(s, e.target.value)}
|
||||||
|
aria-label={`Move ${s.name} to another branch`}
|
||||||
|
className="rounded-lg border border-depth-200 bg-white px-2 py-1 text-xs text-depth-700"
|
||||||
|
>
|
||||||
|
<option value="">Move to…</option>
|
||||||
|
{branches
|
||||||
|
.filter((b) => b._id !== s.branchId)
|
||||||
|
.map((b) => (
|
||||||
|
<option key={b._id} value={b._id}>
|
||||||
|
{b.branchName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{canManage(s) && s.isActive && (
|
||||||
|
<button
|
||||||
|
onClick={() => newPasswordLink(s)}
|
||||||
|
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
|
||||||
|
>
|
||||||
|
{s.invitePending ? "New link" : "Reset password"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canManage(s) && (
|
||||||
|
<button
|
||||||
|
onClick={() => toggleActive(s)}
|
||||||
|
className={
|
||||||
|
"rounded-lg border px-2.5 py-1 text-xs font-medium " +
|
||||||
|
(s.isActive ? "border-alert-500/40 text-alert-600" : "border-confirm-500/40 text-confirm-600")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.isActive ? "Deactivate" : "Reactivate"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -253,12 +402,29 @@ export default function StaffPage() {
|
|||||||
<label className="mb-1 block text-xs font-medium text-depth-600">Email (used to sign in)</label>
|
<label className="mb-1 block text-xs font-medium text-depth-600">Email (used to sign in)</label>
|
||||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{setPasswordNow && (
|
||||||
<label className="mb-1 block text-xs font-medium text-depth-600">Password (used to sign in)</label>
|
<div>
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
<label className="mb-1 block text-xs font-medium text-depth-600">Password (used to sign in)</label>
|
||||||
</div>
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="tap-target w-full rounded-xl border border-depth-200 px-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-depth-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={setPasswordNow}
|
||||||
|
onChange={(e) => setSetPasswordNow(e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-depth-300"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Set their password myself instead of sending an invite link
|
||||||
|
<span className="block text-xs text-depth-500">
|
||||||
|
By default you get a one-time link to send them, and they choose their own password.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{confirmSuperAdmin ? (
|
{confirmSuperAdmin ? (
|
||||||
<div className="mt-4 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-3">
|
<div className="mt-4 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-3">
|
||||||
<p className="mb-2 text-sm text-alert-700">
|
<p className="mb-2 text-sm text-alert-700">
|
||||||
@@ -288,7 +454,7 @@ export default function StaffPage() {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8"
|
className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8"
|
||||||
>
|
>
|
||||||
{saving ? "Adding…" : "Add staff member"}
|
{saving ? "Adding…" : setPasswordNow ? "Add staff member" : "Add and create invite link"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import { AuthUser, Branch, Product, StockLevel, StockMovementRow } from "@/lib/types";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { SkeletonList } from "@/components/ui/Skeleton";
|
||||||
|
import EmptyState from "@/components/ui/EmptyState";
|
||||||
|
import GlobalSwitcher from "@/components/admin/GlobalSwitcher";
|
||||||
|
|
||||||
|
const statusStyles: Record<StockLevel["status"], string> = {
|
||||||
|
OK: "bg-confirm-500/15 text-confirm-600",
|
||||||
|
LOW: "bg-cash-500/15 text-cash-700",
|
||||||
|
OUT: "bg-alert-500/15 text-alert-600",
|
||||||
|
};
|
||||||
|
const statusLabel: Record<StockLevel["status"], string> = { OK: "In stock", LOW: "Low", OUT: "Out" };
|
||||||
|
|
||||||
|
const movementLabel: Record<StockMovementRow["type"], string> = {
|
||||||
|
SALE: "Sale",
|
||||||
|
PURCHASE: "Purchase",
|
||||||
|
TRANSFER_OUT: "Transfer out",
|
||||||
|
TRANSFER_IN: "Transfer in",
|
||||||
|
ADJUSTMENT: "Adjustment",
|
||||||
|
};
|
||||||
|
|
||||||
|
type StockForm = "count" | "transfer" | "daily";
|
||||||
|
|
||||||
|
function newUuid() {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StockPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { show } = useToast();
|
||||||
|
const [me, setMe] = useState<AuthUser | null>(null);
|
||||||
|
const [branches, setBranches] = useState<Branch[]>([]);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [levels, setLevels] = useState<StockLevel[]>([]);
|
||||||
|
const [movements, setMovements] = useState<StockMovementRow[]>([]);
|
||||||
|
const [selectedBranchId, setSelectedBranchId] = useState("ALL");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [form, setForm] = useState<StockForm>("count");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Shared form fields. A fresh clientUuid per submission makes a double
|
||||||
|
// tap on "Save" record the change once.
|
||||||
|
const [formBranchId, setFormBranchId] = useState("");
|
||||||
|
const [toBranchId, setToBranchId] = useState("");
|
||||||
|
const [productId, setProductId] = useState("");
|
||||||
|
const [countMode, setCountMode] = useState<"COUNT" | "DELTA">("COUNT");
|
||||||
|
const [dailyMode, setDailyMode] = useState<"dispatch" | "evening">("dispatch");
|
||||||
|
const [quantity, setQuantity] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [submissionId, setSubmissionId] = useState(newUuid);
|
||||||
|
|
||||||
|
const isSuper = me?.role === "SUPER_ADMIN";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
|
||||||
|
if (result.user.role === "DELIVERY") {
|
||||||
|
router.replace("/terminal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMe(result.user);
|
||||||
|
if (result.user.role !== "SUPER_ADMIN" && result.user.branchId) {
|
||||||
|
setSelectedBranchId(result.user.branchId);
|
||||||
|
setFormBranchId(result.user.branchId);
|
||||||
|
}
|
||||||
|
const [br, pr] = await Promise.all([
|
||||||
|
apiFetch<{ branches: Branch[] }>("/api/branches"),
|
||||||
|
// Deactivated products too: their remaining stock can still be
|
||||||
|
// transferred or counted out.
|
||||||
|
apiFetch<{ products: Product[] }>("/api/products?includeInactive=1"),
|
||||||
|
]);
|
||||||
|
setBranches(br.branches);
|
||||||
|
setProducts(pr.products);
|
||||||
|
} catch {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!me) return;
|
||||||
|
loadStock();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [me, selectedBranchId]);
|
||||||
|
|
||||||
|
async function loadStock() {
|
||||||
|
const q = selectedBranchId === "ALL" ? "" : "?branchId=" + selectedBranchId;
|
||||||
|
try {
|
||||||
|
const [s, m] = await Promise.all([
|
||||||
|
apiFetch<{ levels: StockLevel[] }>("/api/stock" + q),
|
||||||
|
apiFetch<{ movements: StockMovementRow[] }>("/api/stock/movements" + q + (q ? "&" : "?") + "limit=30"),
|
||||||
|
]);
|
||||||
|
setLevels(s.levels);
|
||||||
|
setMovements(m.movements);
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not load stock", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byBranch = useMemo(() => {
|
||||||
|
const groups = new Map<string, { branchName: string; rows: StockLevel[] }>();
|
||||||
|
for (const l of levels) {
|
||||||
|
if (!groups.has(l.branchId)) groups.set(l.branchId, { branchName: l.branchName, rows: [] });
|
||||||
|
groups.get(l.branchId)!.rows.push(l);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries());
|
||||||
|
}, [levels]);
|
||||||
|
|
||||||
|
const alerts = levels.filter((l) => l.status !== "OK").length;
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
setQuantity("");
|
||||||
|
setNote("");
|
||||||
|
setSubmissionId(newUuid());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const qty = parseInt(quantity, 10);
|
||||||
|
const branchId = isSuper ? formBranchId : me?.branchId ?? "";
|
||||||
|
|
||||||
|
if (!productId) return setError("Choose a product.");
|
||||||
|
if (!branchId) return setError(form === "transfer" ? "Choose the sending branch." : "Choose a branch.");
|
||||||
|
if (!Number.isInteger(qty)) return setError("Enter a whole number.");
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (form === "count") {
|
||||||
|
if (countMode === "COUNT" && qty < 0) throw new Error("A counted quantity cannot be negative.");
|
||||||
|
if (countMode === "DELTA" && qty === 0) throw new Error("Enter a non-zero quantity to add or remove.");
|
||||||
|
const res = await apiFetch<{ quantity: number }>("/api/stock/adjustments", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientUuid: submissionId,
|
||||||
|
branchId,
|
||||||
|
productId,
|
||||||
|
mode: countMode,
|
||||||
|
quantity: qty,
|
||||||
|
note: note.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
show(`Stock updated - now ${res.quantity}`, "success");
|
||||||
|
} else if (form === "transfer") {
|
||||||
|
if (qty <= 0) throw new Error("Enter how many to transfer.");
|
||||||
|
if (!toBranchId) throw new Error("Choose the receiving branch.");
|
||||||
|
await apiFetch("/api/stock/transfers", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientUuid: submissionId,
|
||||||
|
fromBranchId: branchId,
|
||||||
|
toBranchId,
|
||||||
|
productId,
|
||||||
|
quantity: qty,
|
||||||
|
note: note.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
show("Transfer recorded at both branches", "success");
|
||||||
|
} else {
|
||||||
|
if (qty < 0) throw new Error("Enter zero or more.");
|
||||||
|
const path = dailyMode === "dispatch" ? "/api/inventory/dispatch" : "/api/inventory/evening-count";
|
||||||
|
const field = dailyMode === "dispatch" ? "morningDispatched" : "eveningPhysicalCount";
|
||||||
|
await apiFetch(path, { method: "POST", body: JSON.stringify({ branchId, productId, [field]: qty }) });
|
||||||
|
show(dailyMode === "dispatch" ? "Morning dispatch saved" : "Evening count saved", "success");
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
await loadStock();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Could not save";
|
||||||
|
setError(message);
|
||||||
|
show(message, "error");
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !me) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-sand-50 pb-12">
|
||||||
|
<header className="bg-depth-900 px-5 pb-8 pt-8">
|
||||||
|
<div className="mx-auto h-14 max-w-4xl" />
|
||||||
|
</header>
|
||||||
|
<div className="mx-auto -mt-4 max-w-4xl px-5">
|
||||||
|
<SkeletonList rows={4} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { id: StockForm; label: string }[] = [
|
||||||
|
{ id: "count", label: "Count / adjust" },
|
||||||
|
{ id: "transfer", label: "Transfer" },
|
||||||
|
{ id: "daily", label: "Daily dispatch" },
|
||||||
|
];
|
||||||
|
const inputClass = "tap-target w-full rounded-xl border border-depth-200 px-3";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-sand-50 pb-12">
|
||||||
|
<header className="bg-depth-900 px-5 pb-8 pt-8 text-white">
|
||||||
|
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-flow-400">{me.businessName ?? "Vessel"}</p>
|
||||||
|
<h1 className="font-display text-xl font-bold">Stock</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<GlobalSwitcher
|
||||||
|
branches={branches}
|
||||||
|
selectedBranchId={selectedBranchId}
|
||||||
|
onChange={setSelectedBranchId}
|
||||||
|
isSuperAdmin={!!isSuper}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href="/admin/dashboard"
|
||||||
|
className="tap-target flex items-center rounded-xl2 bg-white/10 px-4 text-sm font-medium text-white hover:bg-white/15"
|
||||||
|
>
|
||||||
|
← Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mx-auto -mt-4 max-w-4xl space-y-5 px-5">
|
||||||
|
{alerts > 0 && (
|
||||||
|
<div className="rounded-xl2 border border-cash-500/30 bg-cash-500/10 px-4 py-3 text-sm text-cash-700">
|
||||||
|
{alerts} product line(s) are low or out of stock.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{byBranch.length === 0 ? (
|
||||||
|
<div className="rounded-xl2 bg-white shadow-soft">
|
||||||
|
<EmptyState
|
||||||
|
icon="📦"
|
||||||
|
title="No stock to show yet"
|
||||||
|
description="Add branches and products first, then record opening stock below with Count / adjust."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{byBranch.map(([branchId, group]) => (
|
||||||
|
<section key={branchId} className="rounded-xl2 bg-white shadow-soft">
|
||||||
|
<h2 className="border-b border-depth-100 px-4 py-3 font-display font-semibold text-depth-900">
|
||||||
|
{group.branchName}
|
||||||
|
</h2>
|
||||||
|
<ul className="divide-y divide-depth-100">
|
||||||
|
{group.rows.map((l) => (
|
||||||
|
<li key={l.productId} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||||
|
<span className="min-w-0 truncate text-sm text-depth-800">
|
||||||
|
{l.productLabel}
|
||||||
|
{!l.productActive && <span className="ml-1 text-xs text-depth-500">(deactivated)</span>}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className={"font-mono text-sm font-semibold " + (l.quantity < 0 ? "text-alert-600" : "text-depth-900")}>
|
||||||
|
{l.quantity}
|
||||||
|
</span>
|
||||||
|
<span className={"rounded-full px-2 py-0.5 text-xs font-medium " + statusStyles[l.status]}>
|
||||||
|
{statusLabel[l.status]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{levels.some((l) => l.quantity < 0) && (
|
||||||
|
<p className="text-xs text-depth-500">
|
||||||
|
A negative number means sales were recorded for stock the system never received. Do a physical count
|
||||||
|
to correct it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="rounded-xl2 bg-white p-4 shadow-soft">
|
||||||
|
<div role="tablist" className="mb-4 grid grid-cols-3 gap-1 rounded-xl2 bg-sand-100 p-1">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={form === t.id}
|
||||||
|
onClick={() => {
|
||||||
|
setForm(t.id);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
"rounded-xl px-2 py-2 text-xs font-semibold sm:text-sm " +
|
||||||
|
(form === t.id ? "bg-white text-depth-900 shadow-soft" : "text-depth-600")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 text-xs text-depth-500">
|
||||||
|
{form === "count" &&
|
||||||
|
"Record a physical count (the system is corrected to what you counted), or add/remove stock for opening balances, breakage or deliveries not entered as purchases."}
|
||||||
|
{form === "transfer" && "Moves stock out of one branch and into another. Both branches update immediately."}
|
||||||
|
{form === "daily" &&
|
||||||
|
"For delivery crews: how many went out this morning, and how many came back this evening. Feeds the dashboard's reconciliation."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{isSuper && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-branch">
|
||||||
|
{form === "transfer" ? "From branch" : "Branch"}
|
||||||
|
</label>
|
||||||
|
<select id="stock-branch" value={formBranchId} onChange={(e) => setFormBranchId(e.target.value)} className={inputClass}>
|
||||||
|
<option value="">Select a branch…</option>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b._id} value={b._id}>
|
||||||
|
{b.branchName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form === "transfer" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-to">
|
||||||
|
To branch
|
||||||
|
</label>
|
||||||
|
<select id="stock-to" value={toBranchId} onChange={(e) => setToBranchId(e.target.value)} className={inputClass}>
|
||||||
|
<option value="">Select a branch…</option>
|
||||||
|
{branches
|
||||||
|
.filter((b) => b._id !== (isSuper ? formBranchId : me.branchId))
|
||||||
|
.map((b) => (
|
||||||
|
<option key={b._id} value={b._id}>
|
||||||
|
{b.branchName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-product">
|
||||||
|
Product
|
||||||
|
</label>
|
||||||
|
<select id="stock-product" value={productId} onChange={(e) => setProductId(e.target.value)} className={inputClass}>
|
||||||
|
<option value="">Select a product…</option>
|
||||||
|
{products.map((p) => (
|
||||||
|
<option key={p._id} value={p._id}>
|
||||||
|
{p.label}
|
||||||
|
{p.isActive ? "" : " (deactivated)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{form === "count" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-mode">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="stock-mode"
|
||||||
|
value={countMode}
|
||||||
|
onChange={(e) => setCountMode(e.target.value as "COUNT" | "DELTA")}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="COUNT">Physical count (set to)</option>
|
||||||
|
<option value="DELTA">Add or remove (+/-)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form === "daily" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-daily">
|
||||||
|
Entry
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="stock-daily"
|
||||||
|
value={dailyMode}
|
||||||
|
onChange={(e) => setDailyMode(e.target.value as "dispatch" | "evening")}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="dispatch">Morning dispatch</option>
|
||||||
|
<option value="evening">Evening count</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-qty">
|
||||||
|
{form === "count" && countMode === "DELTA" ? "Quantity (use - to remove)" : "Quantity"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stock-qty"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
step="1"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{form !== "daily" && (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-depth-600" htmlFor="stock-note">
|
||||||
|
Note (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="stock-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder={form === "transfer" ? "e.g. Sent with the Thursday van" : "e.g. Opening stock, 3 cans leaking"}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-sm text-alert-600">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="tap-target mt-4 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 disabled:opacity-60 sm:w-auto sm:px-8"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : form === "transfer" ? "Record transfer" : "Save"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="rounded-xl2 bg-white shadow-soft">
|
||||||
|
<h2 className="border-b border-depth-100 px-4 py-3 font-display font-semibold text-depth-900">Recent stock movements</h2>
|
||||||
|
{movements.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-depth-500">No movements yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-depth-100">
|
||||||
|
{movements.map((m) => (
|
||||||
|
<li key={m._id} className="flex items-start justify-between gap-3 px-4 py-2.5 text-sm">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-depth-900">
|
||||||
|
{movementLabel[m.type]} · {m.productId?.label ?? "Product"}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-depth-500">
|
||||||
|
{m.branchId?.branchName ?? ""} · {new Date(m.createdAt).toLocaleString("en-KE")}
|
||||||
|
{m.recordedBy?.name ? " · " + m.recordedBy.name : ""}
|
||||||
|
{m.note ? " · " + m.note : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className={"shrink-0 font-mono font-semibold " + (m.quantity < 0 ? "text-alert-600" : "text-confirm-600")}>
|
||||||
|
{m.quantity > 0 ? "+" : ""}
|
||||||
|
{m.quantity}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@ import { Branch } from "@/lib/server/models/Branch";
|
|||||||
import { Business } from "@/lib/server/models/Business";
|
import { Business } from "@/lib/server/models/Business";
|
||||||
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
||||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||||
import { isPlatformAdminEmail } from "@/lib/server/platform";
|
import { isPlatformAdmin } from "@/lib/server/platform";
|
||||||
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import { getEnv } from "@/lib/server/env";
|
import { getEnv } from "@/lib/server/env";
|
||||||
|
import { tenantAccessError } from "@/lib/server/subscription";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -20,6 +23,9 @@ const loginSchema = z.object({
|
|||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
deviceId: z.string().uuid(),
|
deviceId: z.string().uuid(),
|
||||||
deviceLabel: z.string().max(120).optional(),
|
deviceLabel: z.string().max(120).optional(),
|
||||||
|
// Which business to sign in to, when the same email and password match
|
||||||
|
// accounts in more than one (the first attempt returns the choices).
|
||||||
|
businessId: objectId.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function enforceDeviceBinding(userId: string, deviceId: string) {
|
async function enforceDeviceBinding(userId: string, deviceId: string) {
|
||||||
@@ -43,32 +49,77 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const { email, password, deviceId, deviceLabel } = parsed.data;
|
const { email, password, deviceId, deviceLabel, businessId } = parsed.data;
|
||||||
|
|
||||||
const user = await User.findOne({ email: email.toLowerCase() }).select("+passwordHash");
|
// An email is unique within a business, so it can belong to accounts in
|
||||||
if (!user || !user.isActive) {
|
// several businesses, each with its own password.
|
||||||
|
const filter: Record<string, unknown> = { email: email.toLowerCase(), isActive: true };
|
||||||
|
if (businessId) filter.businessId = businessId;
|
||||||
|
const candidates = await crossTenant("login looks an email up in every business", () =>
|
||||||
|
User.find(filter).select("+passwordHash").exec()
|
||||||
|
);
|
||||||
|
if (candidates.length === 0) {
|
||||||
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
const now = Date.now();
|
||||||
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
|
const unlocked = candidates.filter((u) => !u.lockedUntil || u.lockedUntil.getTime() <= now);
|
||||||
|
if (unlocked.length === 0) {
|
||||||
|
const soonest = Math.min(...candidates.map((u) => u.lockedUntil!.getTime()));
|
||||||
|
const minutesLeft = Math.ceil((soonest - now) / 60000);
|
||||||
return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 });
|
return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPassword = await user.comparePassword(password);
|
const matched = [];
|
||||||
if (!validPassword) {
|
for (const u of unlocked) {
|
||||||
user.failedLoginAttempts += 1;
|
if (await u.comparePassword(password)) matched.push(u);
|
||||||
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
|
}
|
||||||
user.lockedUntil = new Date(Date.now() + LOCKOUT_MS);
|
|
||||||
user.failedLoginAttempts = 0;
|
if (matched.length === 0) {
|
||||||
|
// Count the failure against every account this email could have meant.
|
||||||
|
for (const u of unlocked) {
|
||||||
|
u.failedLoginAttempts += 1;
|
||||||
|
if (u.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
|
||||||
|
u.lockedUntil = new Date(now + LOCKOUT_MS);
|
||||||
|
u.failedLoginAttempts = 0;
|
||||||
|
}
|
||||||
|
await u.save();
|
||||||
}
|
}
|
||||||
await user.save();
|
|
||||||
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
user.failedLoginAttempts = 0;
|
// A correct password unlocks only the accounts it belongs to; a person
|
||||||
user.lockedUntil = null;
|
// with a different password in another business is not affected.
|
||||||
await user.save();
|
for (const u of matched) {
|
||||||
|
u.failedLoginAttempts = 0;
|
||||||
|
u.lockedUntil = null;
|
||||||
|
await u.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matched.length > 1) {
|
||||||
|
// Only someone who knows the password learns which businesses it opens.
|
||||||
|
const businesses = await Business.find({ _id: { $in: matched.map((u) => u.businessId) } })
|
||||||
|
.select("name")
|
||||||
|
.lean();
|
||||||
|
const nameById = new Map(businesses.map((b) => [b._id.toString(), b.name]));
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "This email signs in to more than one business. Choose one.",
|
||||||
|
chooseBusiness: matched.map((u) => ({
|
||||||
|
businessId: u.businessId.toString(),
|
||||||
|
businessName: nameById.get(u.businessId.toString()) ?? "Business",
|
||||||
|
role: u.role,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 300 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const user = matched[0];
|
||||||
|
|
||||||
|
const tenantError = await tenantAccessError(user.businessId);
|
||||||
|
if (tenantError) {
|
||||||
|
return NextResponse.json({ error: tenantError }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
if (user.role === "DELIVERY") {
|
if (user.role === "DELIVERY") {
|
||||||
const binding = await enforceDeviceBinding(user._id.toString(), deviceId);
|
const binding = await enforceDeviceBinding(user._id.toString(), deviceId);
|
||||||
@@ -101,7 +152,7 @@ export async function POST(req: NextRequest) {
|
|||||||
deviceId,
|
deviceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
const branch = user.branchId ? await Branch.findOne({ _id: user.branchId, businessId: user.businessId }).lean() : null;
|
||||||
const business = await Business.findById(user.businessId).lean();
|
const business = await Business.findById(user.businessId).lean();
|
||||||
|
|
||||||
const res = NextResponse.json({
|
const res = NextResponse.json({
|
||||||
@@ -116,8 +167,10 @@ export async function POST(req: NextRequest) {
|
|||||||
phoneNumber: user.phoneNumber,
|
phoneNumber: user.phoneNumber,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
// Platform operators start on the console for every business, not on
|
||||||
isPlatformAdmin: isPlatformAdminEmail(user.email),
|
// their own (placeholder) business's dashboard.
|
||||||
|
redirectTo: isPlatformAdmin(user) ? "/platform" : user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
||||||
|
isPlatformAdmin: isPlatformAdmin(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { User } from "@/lib/server/models/User";
|
|||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { Business } from "@/lib/server/models/Business";
|
import { Business } from "@/lib/server/models/Business";
|
||||||
import { getAuth } from "@/lib/server/auth";
|
import { getAuth } from "@/lib/server/auth";
|
||||||
import { isPlatformAdminEmail } from "@/lib/server/platform";
|
import { isPlatformAdmin } from "@/lib/server/platform";
|
||||||
import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
import { isMpesaEnabled } from "@/lib/server/env";
|
||||||
|
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -15,11 +16,11 @@ export async function GET(req: NextRequest) {
|
|||||||
const auth = getAuth(req);
|
const auth = getAuth(req);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
|
||||||
const user = await User.findById(auth.sub).lean();
|
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId }).lean();
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
}
|
}
|
||||||
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
const branch = user.branchId ? await Branch.findOne({ _id: user.branchId, businessId: user.businessId }).lean() : null;
|
||||||
const business = await Business.findById(user.businessId).lean();
|
const business = await Business.findById(user.businessId).lean();
|
||||||
|
|
||||||
const tier = (business?.tier as TierId) ?? "STARTER";
|
const tier = (business?.tier as TierId) ?? "STARTER";
|
||||||
@@ -37,7 +38,7 @@ export async function GET(req: NextRequest) {
|
|||||||
branchName: branch?.branchName ?? null,
|
branchName: branch?.branchName ?? null,
|
||||||
phoneNumber: user.phoneNumber,
|
phoneNumber: user.phoneNumber,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
isPlatformAdmin: isPlatformAdminEmail(user.email),
|
isPlatformAdmin: isPlatformAdmin(user),
|
||||||
},
|
},
|
||||||
subscription: {
|
subscription: {
|
||||||
tier,
|
tier,
|
||||||
@@ -46,8 +47,9 @@ export async function GET(req: NextRequest) {
|
|||||||
status,
|
status,
|
||||||
trialEndsAt: business?.trialEndsAt ?? null,
|
trialEndsAt: business?.trialEndsAt ?? null,
|
||||||
currentPeriodEnd: business?.currentPeriodEnd ?? null,
|
currentPeriodEnd: business?.currentPeriodEnd ?? null,
|
||||||
maxBranches: tierDef.maxBranches,
|
maxBranches: effectiveMaxBranches(tier, status),
|
||||||
maxUsers: tierDef.maxUsers,
|
maxUsers: tierDef.maxUsers,
|
||||||
},
|
},
|
||||||
|
features: { mpesa: isMpesaEnabled() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
|
import { authorize } from "@/lib/server/auth";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const changeSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1, "Enter your current password"),
|
||||||
|
newPassword: z.string().min(8, "The new password must be at least 8 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/password — the signed-in user changes their own password.
|
||||||
|
* Every other device is signed out; this one stays signed in.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
const parsed = changeSchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { currentPassword, newPassword } = parsed.data;
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
return NextResponse.json({ error: "Choose a password different from the current one" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId }).select("+passwordHash");
|
||||||
|
if (!user || !user.isActive) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
if (!(await user.comparePassword(currentPassword))) {
|
||||||
|
return NextResponse.json({ error: "Your current password is not correct" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await user.save();
|
||||||
|
await UserSession.deleteMany({ userId: user._id, deviceId: { $ne: auth.deviceId } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { connectDb } from "@/lib/server/db";
|
|||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { UserSession } from "@/lib/server/models/UserSession";
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
||||||
|
import { tenantAccessError } from "@/lib/server/subscription";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -23,12 +25,19 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 });
|
return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findById(session.userId);
|
const user = await crossTenant("a refresh session identifies its user before the business is known", () =>
|
||||||
|
User.findById(session.userId).exec()
|
||||||
|
);
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
await UserSession.deleteOne({ _id: session._id });
|
await UserSession.deleteOne({ _id: session._id });
|
||||||
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
|
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tenantError = await tenantAccessError(user.businessId);
|
||||||
|
if (tenantError) {
|
||||||
|
return NextResponse.json({ error: tenantError }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const newRefreshToken = generateRefreshToken();
|
const newRefreshToken = generateRefreshToken();
|
||||||
session.refreshTokenHash = hashRefreshToken(newRefreshToken);
|
session.refreshTokenHash = hashRefreshToken(newRefreshToken);
|
||||||
session.lastUsedAt = new Date();
|
session.lastUsedAt = new Date();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs }
|
|||||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||||
import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers";
|
import { DEFAULT_TIER, TRIAL_DAYS } from "@/lib/tiers";
|
||||||
|
import { isPlatformAdminEmail } from "@/lib/server/platform";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ const DEFAULT_CATALOG = [
|
|||||||
{ label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
|
{ label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
|
||||||
{ label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
|
{ label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
|
||||||
{ label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
|
{ label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
|
||||||
{ label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
{ label: "Tray of Eggs (30)", sizeLiters: 1, unit: "tray", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
||||||
{ label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
|
{ label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
|
||||||
{ label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
|
{ label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
|
||||||
];
|
];
|
||||||
@@ -52,15 +53,17 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const lowerEmail = email.toLowerCase();
|
const lowerEmail = email.toLowerCase();
|
||||||
|
|
||||||
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
|
// Operator addresses are reserved, so nobody can open a business that
|
||||||
if (emailTaken) {
|
// looks like the operator's. (Access itself also needs the platformAdmin
|
||||||
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
|
// flag, which signup never sets.)
|
||||||
}
|
if (isPlatformAdminEmail(lowerEmail)) {
|
||||||
const phoneTaken = await User.findOne({ phoneNumber: normalizedPhone }).lean();
|
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
|
||||||
if (phoneTaken) {
|
|
||||||
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No platform-wide duplicate check: a new business has no users yet, and
|
||||||
|
// the same person may already work for another business. (Checking would
|
||||||
|
// also tell anyone whether an email or phone is registered elsewhere.)
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
const ownerId = new mongoose.Types.ObjectId();
|
const ownerId = new mongoose.Types.ObjectId();
|
||||||
@@ -91,7 +94,7 @@ export async function POST(req: NextRequest) {
|
|||||||
await owner.save();
|
await owner.save();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await Business.deleteOne({ _id: businessId }).catch(() => {});
|
await Business.deleteOne({ _id: businessId }).catch(() => {});
|
||||||
await User.deleteOne({ _id: ownerId }).catch(() => {});
|
await User.deleteOne({ _id: ownerId, businessId }).catch(() => {});
|
||||||
const msg = (e as Error).message || "";
|
const msg = (e as Error).message || "";
|
||||||
if (msg.includes("duplicate key")) {
|
if (msg.includes("duplicate key")) {
|
||||||
return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 });
|
return NextResponse.json({ error: "An account with this email or phone already exists." }, { status: 409 });
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
||||||
|
import { authorize, badRequest, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
import { canUseBranchPricing, type SubscriptionStatus, type TierId } from "@/lib/tiers";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Branch-specific prices. A branch charges its own price for a product when
|
||||||
|
* one is set here, otherwise the product's default price. The till, the
|
||||||
|
* cash-sale route and M-Pesa all read prices the same way, so a change here
|
||||||
|
* applies to the next sale at that branch (a phone that is offline picks it
|
||||||
|
* up when it next loads products; a sale it records meanwhile is kept at the
|
||||||
|
* server's price and flagged as a price mismatch).
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function pricingAllowed(businessId: string): Promise<boolean> {
|
||||||
|
const biz = await Business.findById(businessId).select("tier subscriptionStatus").lean();
|
||||||
|
return canUseBranchPricing((biz?.tier as TierId) ?? "STARTER", (biz?.subscriptionStatus as SubscriptionStatus) ?? "TRIALING");
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/branch-prices?branchId= — every product with its default and
|
||||||
|
// branch price. Owners pick any branch; managers see their own.
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, MANAGERS);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
const requested = req.nextUrl.searchParams.get("branchId");
|
||||||
|
const branchId = auth.role === "SUPER_ADMIN" ? requested : auth.branchId;
|
||||||
|
if (!branchId) return badRequest("branchId is required");
|
||||||
|
if (!objectId.safeParse(branchId).success) return badRequest("Invalid branchId");
|
||||||
|
|
||||||
|
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
||||||
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
|
const [products, overrides, allowed] = await Promise.all([
|
||||||
|
Product.find({ businessId: auth.businessId }).sort({ sortOrder: 1 }).lean(),
|
||||||
|
BranchPricing.find({ businessId: auth.businessId, branchId }).lean(),
|
||||||
|
pricingAllowed(auth.businessId),
|
||||||
|
]);
|
||||||
|
const byProduct = new Map(overrides.map((o) => [o.productId.toString(), o]));
|
||||||
|
|
||||||
|
const prices = products
|
||||||
|
// Deactivated products only matter here if they still carry a branch price.
|
||||||
|
.filter((p) => p.isActive || byProduct.has(p._id.toString()))
|
||||||
|
.map((p) => {
|
||||||
|
const o = byProduct.get(p._id.toString());
|
||||||
|
return {
|
||||||
|
productId: p._id.toString(),
|
||||||
|
label: p.label,
|
||||||
|
unit: p.unit,
|
||||||
|
sizeLiters: p.sizeLiters,
|
||||||
|
isActive: p.isActive,
|
||||||
|
defaultPrice: p.unitPrice,
|
||||||
|
branchPrice: o?.unitPrice ?? null,
|
||||||
|
updatedAt: o?.updatedAt ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ branchId, branchName: branch.branchName, allowed, prices });
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSchema = z.object({
|
||||||
|
branchId: objectId,
|
||||||
|
productId: objectId,
|
||||||
|
unitPrice: z.number().positive("Price must be more than zero").max(10_000_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/branch-prices — owner sets one product's price at one branch.
|
||||||
|
// Setting it to the default price removes the override.
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, ["SUPER_ADMIN"]);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
const parsed = setSchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) return badRequest(parsed.error.issues[0]?.message ?? "Invalid request");
|
||||||
|
const { branchId, productId, unitPrice } = parsed.data;
|
||||||
|
|
||||||
|
if (!(await pricingAllowed(auth.businessId))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Branch prices are part of the Growth plan. Upgrade to set a different price per branch." },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [branch, product] = await Promise.all([
|
||||||
|
Branch.exists({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }),
|
||||||
|
Product.findOne({ _id: productId, businessId: auth.businessId }).select("unitPrice").lean(),
|
||||||
|
]);
|
||||||
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
|
if (unitPrice === product.unitPrice) {
|
||||||
|
await BranchPricing.deleteOne({ businessId: auth.businessId, branchId, productId });
|
||||||
|
return NextResponse.json({ branchId, productId, branchPrice: null, defaultPrice: product.unitPrice });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await BranchPricing.findOneAndUpdate(
|
||||||
|
{ businessId: auth.businessId, branchId, productId },
|
||||||
|
{ businessId: auth.businessId, unitPrice, updatedBy: auth.sub },
|
||||||
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
|
).lean();
|
||||||
|
return NextResponse.json({ branchId, productId, branchPrice: row!.unitPrice, defaultPrice: product.unitPrice });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/branch-prices?branchId=&productId= — back to the default price.
|
||||||
|
// Always allowed, even after the plan no longer includes branch prices.
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, ["SUPER_ADMIN"]);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
const branchId = req.nextUrl.searchParams.get("branchId");
|
||||||
|
const productId = req.nextUrl.searchParams.get("productId");
|
||||||
|
if (!objectId.safeParse(branchId).success || !objectId.safeParse(productId).success) {
|
||||||
|
return badRequest("branchId and productId are required");
|
||||||
|
}
|
||||||
|
await BranchPricing.deleteOne({ businessId: auth.businessId, branchId, productId });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { authorize } from "@/lib/server/auth";
|
||||||
|
import { branchLimitError } from "@/lib/server/subscription";
|
||||||
|
import { getStockLevels } from "@/lib/server/stock";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const updateSchema = z
|
||||||
|
.object({
|
||||||
|
branchName: z.string().trim().min(1).max(120).optional(),
|
||||||
|
locationCity: z.string().trim().min(1).max(120).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine((v) => Object.keys(v).length > 0, { message: "No fields to update" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/branches/:id — owner renames a branch, or deactivates /
|
||||||
|
* reactivates it. This is how an owner "removes" a branch: nothing is
|
||||||
|
* deleted, so its sales, purchases and stock history stay in every report.
|
||||||
|
*
|
||||||
|
* A branch can only be deactivated once it is empty, so nothing is left
|
||||||
|
* stranded in it:
|
||||||
|
* - no stock on hand (transfer it out or count it to zero first), and
|
||||||
|
* - no active staff (move or deactivate them first).
|
||||||
|
* Reactivating counts against the plan's branch limit again.
|
||||||
|
*/
|
||||||
|
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, ["SUPER_ADMIN"]);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const json = await req.json().catch(() => null);
|
||||||
|
const parsed = updateSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { isActive, ...details } = parsed.data;
|
||||||
|
|
||||||
|
const branch = await Branch.findOne({ _id: params.id, businessId: auth.businessId });
|
||||||
|
if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
|
||||||
|
const wasActive = branch.isActive !== false;
|
||||||
|
|
||||||
|
if (isActive === false && wasActive) {
|
||||||
|
const stock = (await getStockLevels(auth.businessId, branch._id)).filter((l) => l.quantity !== 0);
|
||||||
|
if (stock.length > 0) {
|
||||||
|
const list = stock.map((l) => `${l.quantity} x ${l.productLabel}`).join(", ");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Move or count out this branch's stock first (${list}).`, stock },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const staff = await User.countDocuments({ businessId: auth.businessId, branchId: branch._id, isActive: true });
|
||||||
|
if (staff > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `${staff} active staff member(s) still work at this branch. Move or deactivate them first.` },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isActive === true && !wasActive) {
|
||||||
|
const limitError = await branchLimitError(auth.businessId);
|
||||||
|
if (limitError) return NextResponse.json({ error: limitError }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(branch, details);
|
||||||
|
if (isActive !== undefined) branch.isActive = isActive;
|
||||||
|
await branch.save();
|
||||||
|
return NextResponse.json({ branch });
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { getAuth, requireRole } from "@/lib/server/auth";
|
import { authorize, getAuth } from "@/lib/server/auth";
|
||||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||||
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
|
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
|
||||||
|
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -17,7 +18,11 @@ export async function GET(req: NextRequest) {
|
|||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
|
||||||
if (auth.role === "SUPER_ADMIN") {
|
if (auth.role === "SUPER_ADMIN") {
|
||||||
const branches = await Branch.find({ businessId: auth.businessId }).sort({ branchName: 1 }).lean();
|
// Deactivated branches are only listed on request (the Branches page),
|
||||||
|
// so every picker elsewhere offers active branches only.
|
||||||
|
const includeInactive = req.nextUrl.searchParams.get("includeInactive") === "1";
|
||||||
|
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, ...ACTIVE_BRANCH };
|
||||||
|
const branches = await Branch.find(filter).sort({ branchName: 1 }).lean();
|
||||||
return NextResponse.json({ branches });
|
return NextResponse.json({ branches });
|
||||||
}
|
}
|
||||||
if (!auth.branchId) return NextResponse.json({ branches: [] });
|
if (!auth.branchId) return NextResponse.json({ branches: [] });
|
||||||
@@ -33,17 +38,17 @@ const createSchema = z.object({
|
|||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
phoneNumber: z.string().min(9).max(15),
|
phoneNumber: z.string().min(9).max(15),
|
||||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
// Omit to invite: the response carries a one-time link for the manager.
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters").optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, ["SUPER_ADMIN"]);
|
||||||
if (!requireRole(auth, "SUPER_ADMIN")) {
|
if (!gate.ok) return gate.response;
|
||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
const auth = gate.auth;
|
||||||
}
|
|
||||||
|
|
||||||
const json = await req.json().catch(() => null);
|
const json = await req.json().catch(() => null);
|
||||||
const parsed = createSchema.safeParse(json);
|
const parsed = createSchema.safeParse(json);
|
||||||
@@ -51,10 +56,10 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const { branchName, locationCity, manager } = parsed.data;
|
const { branchName, locationCity, manager } = parsed.data;
|
||||||
const businessId = auth!.businessId;
|
const businessId = auth.businessId;
|
||||||
|
|
||||||
const tier = await loadTier(businessId);
|
const tier = await loadTier(businessId);
|
||||||
const branchErr = await branchLimitError(businessId, tier);
|
const branchErr = await branchLimitError(businessId);
|
||||||
if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 });
|
if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 });
|
||||||
|
|
||||||
let managerPhone: string | null = null;
|
let managerPhone: string | null = null;
|
||||||
@@ -66,14 +71,15 @@ export async function POST(req: NextRequest) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
|
return NextResponse.json({ error: (e as Error).message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
// Unique within this business only (see the User model).
|
||||||
const lowerEmail = manager.email.toLowerCase();
|
const lowerEmail = manager.email.toLowerCase();
|
||||||
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
|
const emailTaken = await User.exists({ businessId, email: lowerEmail });
|
||||||
if (emailTaken) {
|
if (emailTaken) {
|
||||||
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
|
return NextResponse.json({ error: "Someone in your business already uses this email." }, { status: 409 });
|
||||||
}
|
}
|
||||||
const phoneTaken = await User.findOne({ phoneNumber: managerPhone }).lean();
|
const phoneTaken = await User.exists({ businessId, phoneNumber: managerPhone });
|
||||||
if (phoneTaken) {
|
if (phoneTaken) {
|
||||||
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
|
return NextResponse.json({ error: "Someone in your business already uses this phone number." }, { status: 409 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +87,8 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
if (manager && managerPhone) {
|
if (manager && managerPhone) {
|
||||||
try {
|
try {
|
||||||
const passwordHash = await bcrypt.hash(manager.password, 12);
|
const passwordHash = manager.password ? await bcrypt.hash(manager.password, 12) : await unusablePasswordHash();
|
||||||
const managerUser = await User.create({
|
const managerUser = new User({
|
||||||
businessId,
|
businessId,
|
||||||
name: manager.name,
|
name: manager.name,
|
||||||
role: "BRANCH_MANAGER",
|
role: "BRANCH_MANAGER",
|
||||||
@@ -91,16 +97,18 @@ export async function POST(req: NextRequest) {
|
|||||||
email: manager.email.toLowerCase(),
|
email: manager.email.toLowerCase(),
|
||||||
passwordHash,
|
passwordHash,
|
||||||
});
|
});
|
||||||
|
const inviteUrl = manager.password ? null : issueInvite(managerUser, req);
|
||||||
|
await managerUser.save();
|
||||||
branch.managerId = managerUser._id;
|
branch.managerId = managerUser._id;
|
||||||
await branch.save();
|
await branch.save();
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email } },
|
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email }, inviteUrl },
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
await Branch.deleteOne({ _id: branch._id }).catch(() => {});
|
await Branch.deleteOne({ _id: branch._id, businessId }).catch(() => {});
|
||||||
if (err?.code === 11000) {
|
if (err?.code === 11000) {
|
||||||
return NextResponse.json({ error: "A user with this phone number or email already exists." }, { status: 409 });
|
return NextResponse.json({ error: "Someone in your business already uses this phone number or email." }, { status: 409 });
|
||||||
}
|
}
|
||||||
return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
|
return NextResponse.json({ error: "Could not create the branch manager. Please try again." }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,48 @@ import mongoose from "mongoose";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
||||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
import { authorize, badRequest, branchParam } from "@/lib/server/auth";
|
||||||
|
import { dayRangeNairobi, isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10);
|
if (!scope.ok) return scope.response;
|
||||||
|
const branchId = scope.branchId;
|
||||||
|
const date = req.nextUrl.searchParams.get("date") || todayNairobi();
|
||||||
|
if (!isBusinessDate(date)) return badRequest("date must be YYYY-MM-DD");
|
||||||
|
|
||||||
const invMatch: Record<string, unknown> = { date, businessId: new mongoose.Types.ObjectId(auth.businessId) };
|
const invMatch: Record<string, unknown> = { date, businessId: new mongoose.Types.ObjectId(auth.businessId) };
|
||||||
if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId);
|
if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId);
|
||||||
|
|
||||||
const inventoryRows = await DailyInventory.aggregate([
|
const inventoryRows = await DailyInventory.aggregate([
|
||||||
{ $match: invMatch },
|
{ $match: invMatch },
|
||||||
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "product" } },
|
// Lookups are tenant-scoped too, so a row can never surface another
|
||||||
|
// tenant's product or branch even if one were referenced by mistake.
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: "products",
|
||||||
|
let: { pid: "$productId", biz: "$businessId" },
|
||||||
|
pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$pid"] }, { $eq: ["$businessId", "$$biz"] }] } } }],
|
||||||
|
as: "product",
|
||||||
|
},
|
||||||
|
},
|
||||||
{ $unwind: "$product" },
|
{ $unwind: "$product" },
|
||||||
{ $lookup: { from: "branches", localField: "branchId", foreignField: "_id", as: "branch" } },
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: "branches",
|
||||||
|
let: { bid: "$branchId", biz: "$businessId" },
|
||||||
|
pipeline: [{ $match: { $expr: { $and: [{ $eq: ["$_id", "$$bid"] }, { $eq: ["$businessId", "$$biz"] }] } } }],
|
||||||
|
as: "branch",
|
||||||
|
},
|
||||||
|
},
|
||||||
{ $unwind: "$branch" },
|
{ $unwind: "$branch" },
|
||||||
{
|
{
|
||||||
$project: {
|
$project: {
|
||||||
@@ -39,9 +60,7 @@ export async function GET(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const dayStart = new Date(date + "T00:00:00.000Z");
|
const { start: dayStart, end: dayEnd } = dayRangeNairobi(date);
|
||||||
const dayEnd = new Date(dayStart);
|
|
||||||
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
|
|
||||||
|
|
||||||
const soldMatch: Record<string, unknown> = {
|
const soldMatch: Record<string, unknown> = {
|
||||||
businessId: new mongoose.Types.ObjectId(auth.businessId),
|
businessId: new mongoose.Types.ObjectId(auth.businessId),
|
||||||
|
|||||||
@@ -2,26 +2,22 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
import { authorize, branchParam } from "@/lib/server/auth";
|
||||||
|
import { dayRangeNairobi, todayNairobi } from "@/lib/server/businessDay";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
function todayRange() {
|
|
||||||
const start = new Date();
|
|
||||||
start.setHours(0, 0, 0, 0);
|
|
||||||
const end = new Date(start);
|
|
||||||
end.setDate(end.getDate() + 1);
|
|
||||||
return { start, end };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
const { start, end } = todayRange();
|
if (!scope.ok) return scope.response;
|
||||||
|
const branchId = scope.branchId;
|
||||||
|
const { start, end } = dayRangeNairobi(todayNairobi());
|
||||||
|
|
||||||
const matchStage: Record<string, unknown> = {
|
const matchStage: Record<string, unknown> = {
|
||||||
businessId: new mongoose.Types.ObjectId(auth.businessId),
|
businessId: new mongoose.Types.ObjectId(auth.businessId),
|
||||||
|
|||||||
@@ -3,21 +3,26 @@ import { z } from "zod";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
|
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// GET /api/expenses?branchId=&from=&to=
|
// GET /api/expenses?branchId=&from=&to=
|
||||||
// SUPER_ADMIN sees all (optionally filtered by branch); everyone else is
|
// Managers and owners only. SUPER_ADMIN sees all (optionally filtered by
|
||||||
// hard-scoped to their own branch regardless of query params.
|
// branch); a BRANCH_MANAGER is hard-scoped to their own branch.
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, MANAGERS);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
const scope = branchParam(auth, url.searchParams.get("branchId"));
|
||||||
|
if (!scope.ok) return scope.response;
|
||||||
|
const scopeBranch = scope.branchId;
|
||||||
|
|
||||||
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
if (scopeBranch) filter.branchId = scopeBranch;
|
if (scopeBranch) filter.branchId = scopeBranch;
|
||||||
@@ -32,8 +37,8 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expenses = await Expense.find(filter)
|
const expenses = await Expense.find(filter)
|
||||||
.populate("branchId", "branchName")
|
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
|
||||||
.populate("recordedBy", "name")
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(500)
|
.limit(500)
|
||||||
.lean();
|
.lean();
|
||||||
@@ -42,7 +47,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
branchId: z.string().trim().min(1).optional(),
|
branchId: objectId.optional(),
|
||||||
category: z.enum(EXPENSE_CATEGORIES),
|
category: z.enum(EXPENSE_CATEGORIES),
|
||||||
amount: z.number().positive().max(100_000_000),
|
amount: z.number().positive().max(100_000_000),
|
||||||
description: z.string().trim().min(1).max(300),
|
description: z.string().trim().min(1).max(300),
|
||||||
@@ -50,12 +55,13 @@ const createSchema = z.object({
|
|||||||
notes: z.string().trim().max(500).optional(),
|
notes: z.string().trim().max(500).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/expenses — any authenticated staff can record an expense, but
|
// POST /api/expenses — managers and owners only, and only against a branch
|
||||||
// only against a branch they are allowed to touch.
|
// they are allowed to touch.
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, MANAGERS);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
const json = await req.json().catch(() => null);
|
const json = await req.json().catch(() => null);
|
||||||
const parsed = createSchema.safeParse(json);
|
const parsed = createSchema.safeParse(json);
|
||||||
|
|||||||
@@ -3,27 +3,25 @@ import { z } from "zod";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth";
|
||||||
|
import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
|
||||||
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
function todayLocal(): string {
|
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dispatchSchema = z.object({
|
const dispatchSchema = z.object({
|
||||||
productId: z.string().min(1),
|
productId: objectId,
|
||||||
morningDispatched: z.number().int().min(0),
|
morningDispatched: z.number().int().min(0),
|
||||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(),
|
||||||
branchId: z.string().optional(),
|
branchId: objectId.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, MANAGERS);
|
||||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
if (!gate.ok) return gate.response;
|
||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
const auth = gate.auth;
|
||||||
}
|
|
||||||
|
|
||||||
const json = await req.json().catch(() => null);
|
const json = await req.json().catch(() => null);
|
||||||
const parsed = dispatchSchema.safeParse(json);
|
const parsed = dispatchSchema.safeParse(json);
|
||||||
@@ -31,18 +29,22 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
|
const branchId = resolveBranchScope(auth, parsed.data.branchId);
|
||||||
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
||||||
|
|
||||||
// The branch MUST belong to this tenant — blocks a crafted branchId from
|
// The branch MUST belong to this tenant — blocks a crafted branchId from
|
||||||
// writing inventory into another business.
|
// writing inventory into another business.
|
||||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
|
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
||||||
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
const date = parsed.data.date ?? todayLocal();
|
// The product must belong to this tenant too.
|
||||||
|
const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId });
|
||||||
|
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
|
const date = parsed.data.date ?? todayNairobi();
|
||||||
const row = await DailyInventory.findOneAndUpdate(
|
const row = await DailyInventory.findOneAndUpdate(
|
||||||
{ branchId, productId: parsed.data.productId, date },
|
{ businessId: auth.businessId, branchId, productId: parsed.data.productId, date },
|
||||||
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub, businessId: auth!.businessId },
|
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth.sub, businessId: auth.businessId },
|
||||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
);
|
);
|
||||||
return NextResponse.json({ inventory: row });
|
return NextResponse.json({ inventory: row });
|
||||||
|
|||||||
@@ -3,27 +3,25 @@ import { z } from "zod";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
import { authorize, MANAGERS, resolveBranchScope } from "@/lib/server/auth";
|
||||||
|
import { isBusinessDate, todayNairobi } from "@/lib/server/businessDay";
|
||||||
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
function todayLocal(): string {
|
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const countSchema = z.object({
|
const countSchema = z.object({
|
||||||
productId: z.string().min(1),
|
productId: objectId,
|
||||||
eveningPhysicalCount: z.number().int().min(0),
|
eveningPhysicalCount: z.number().int().min(0),
|
||||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
date: z.string().refine(isBusinessDate, { message: "date must be YYYY-MM-DD" }).optional(),
|
||||||
branchId: z.string().optional(),
|
branchId: objectId.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, MANAGERS);
|
||||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
if (!gate.ok) return gate.response;
|
||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
const auth = gate.auth;
|
||||||
}
|
|
||||||
|
|
||||||
const json = await req.json().catch(() => null);
|
const json = await req.json().catch(() => null);
|
||||||
const parsed = countSchema.safeParse(json);
|
const parsed = countSchema.safeParse(json);
|
||||||
@@ -31,17 +29,21 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
|
const branchId = resolveBranchScope(auth, parsed.data.branchId);
|
||||||
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
||||||
|
|
||||||
// The branch MUST belong to this tenant.
|
// The branch MUST belong to this tenant.
|
||||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
|
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
||||||
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
const date = parsed.data.date ?? todayLocal();
|
// The product must belong to this tenant too.
|
||||||
|
const product = await Product.exists({ _id: parsed.data.productId, businessId: auth.businessId });
|
||||||
|
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
|
const date = parsed.data.date ?? todayNairobi();
|
||||||
const row = await DailyInventory.findOneAndUpdate(
|
const row = await DailyInventory.findOneAndUpdate(
|
||||||
{ branchId, productId: parsed.data.productId, date },
|
{ businessId: auth.businessId, branchId, productId: parsed.data.productId, date },
|
||||||
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub, businessId: auth!.businessId },
|
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth.sub, businessId: auth.businessId },
|
||||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
);
|
);
|
||||||
return NextResponse.json({ inventory: row });
|
return NextResponse.json({ inventory: row });
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
|
import { hashInviteToken } from "@/lib/server/invites";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function findInvitee(token: string) {
|
||||||
|
if (!token || token.length > 100) return null;
|
||||||
|
const user = await crossTenant("an invite token identifies its account before sign-in", () =>
|
||||||
|
User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true }).exec()
|
||||||
|
);
|
||||||
|
if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID = { error: "This invite link is invalid or has expired. Ask your manager for a new one." };
|
||||||
|
|
||||||
|
// GET /api/invites/:token — who the invite is for, so the page can greet them.
|
||||||
|
export async function GET(_req: NextRequest, { params }: { params: { token: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const user = await findInvitee(params.token);
|
||||||
|
if (!user) return NextResponse.json(INVALID, { status: 404 });
|
||||||
|
const business = await Business.findById(user.businessId).select("name").lean();
|
||||||
|
return NextResponse.json({
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
businessName: business?.name ?? null,
|
||||||
|
purpose: user.invitePurpose === "reset" ? "reset" : "invite",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptSchema = z.object({ password: z.string().min(8, "Password must be at least 8 characters") });
|
||||||
|
|
||||||
|
// POST /api/invites/:token — set the password; the link then stops working.
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const parsed = acceptSchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const user = await findInvitee(params.token);
|
||||||
|
if (!user) return NextResponse.json(INVALID, { status: 404 });
|
||||||
|
|
||||||
|
user.passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
|
user.inviteTokenHash = null;
|
||||||
|
user.inviteExpiresAt = null;
|
||||||
|
user.invitePurpose = null;
|
||||||
|
user.failedLoginAttempts = 0;
|
||||||
|
user.lockedUntil = null;
|
||||||
|
await user.save();
|
||||||
|
// A new password ends every existing session, so whoever had the old one
|
||||||
|
// (a lost phone, a former colleague) is signed out.
|
||||||
|
await UserSession.deleteMany({ userId: user._id });
|
||||||
|
return NextResponse.json({ ok: true, email: user.email });
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { isMpesaEnabled } from "@/lib/server/env";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
|
import { recordSaleMovement } from "@/lib/server/stock";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
|
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -18,6 +21,7 @@ function isIpAllowed(ip: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
|
const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
|
||||||
const sourceIp = getSourceIp(req);
|
const sourceIp = getSourceIp(req);
|
||||||
@@ -66,7 +70,9 @@ export async function POST(req: NextRequest) {
|
|||||||
mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null;
|
mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const txn = await Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId });
|
const txn = await crossTenant("the payment provider's callback is matched by checkout id", () =>
|
||||||
|
Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId }).exec()
|
||||||
|
);
|
||||||
|
|
||||||
if (!txn) {
|
if (!txn) {
|
||||||
await MpesaCallbackLog.create({
|
await MpesaCallbackLog.create({
|
||||||
@@ -97,6 +103,7 @@ export async function POST(req: NextRequest) {
|
|||||||
txn.mpesaDetails.resultDesc = resultDesc;
|
txn.mpesaDetails.resultDesc = resultDesc;
|
||||||
txn.mpesaDetails.receiptNumber = mpesaReceiptNumber;
|
txn.mpesaDetails.receiptNumber = mpesaReceiptNumber;
|
||||||
await txn.save();
|
await txn.save();
|
||||||
|
await recordSaleMovement(txn);
|
||||||
|
|
||||||
await MpesaCallbackLog.create({
|
await MpesaCallbackLog.create({
|
||||||
sourceIp,
|
sourceIp,
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { isMpesaEnabled } from "@/lib/server/env";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
import { getAuth } from "@/lib/server/auth";
|
import { getAuth } from "@/lib/server/auth";
|
||||||
import { queryStkStatus } from "@/lib/server/mpesa";
|
import { queryStkStatus } from "@/lib/server/mpesa";
|
||||||
|
import { recordSaleMovement } from "@/lib/server/stock";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) {
|
export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) {
|
||||||
|
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const auth = getAuth(req);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||||
|
|
||||||
|
if (!isValidObjectId(params.transactionId)) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
|
||||||
const txn = await Transaction.findOne({ _id: params.transactionId, businessId: auth.businessId });
|
const txn = await Transaction.findOne({ _id: params.transactionId, businessId: auth.businessId });
|
||||||
if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
|
if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
|
||||||
|
|
||||||
@@ -21,12 +26,13 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI
|
|||||||
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
|
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
|
||||||
const code = Number(result.ResultCode);
|
const code = Number(result.ResultCode);
|
||||||
if (!Number.isNaN(code) && code !== 1032) {
|
if (!Number.isNaN(code) && code !== 1032) {
|
||||||
const fresh = await Transaction.findOne({ _id: txn._id, paymentStatus: "PENDING" });
|
const fresh = await Transaction.findOne({ _id: txn._id, businessId: auth.businessId, paymentStatus: "PENDING" });
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
|
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
|
||||||
fresh.mpesaDetails.resultCode = code;
|
fresh.mpesaDetails.resultCode = code;
|
||||||
fresh.mpesaDetails.resultDesc = result.ResultDesc;
|
fresh.mpesaDetails.resultDesc = result.ResultDesc;
|
||||||
await fresh.save();
|
await fresh.save();
|
||||||
|
await recordSaleMovement(fresh);
|
||||||
return NextResponse.json(fresh);
|
return NextResponse.json(fresh);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { isMpesaEnabled } from "@/lib/server/env";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
@@ -31,6 +32,7 @@ function rateLimited(key: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!isMpesaEnabled()) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const auth = getAuth(req);
|
||||||
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
|
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { requirePlatformAdmin } from "@/lib/server/platform";
|
||||||
|
import { invitePending, issueInvite } from "@/lib/server/invites";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/platform/businesses/:id/owner-reset — operator only. A one-time
|
||||||
|
* password link for a business's owner of record: the way back in for an
|
||||||
|
* owner who has forgotten their password (nobody inside the business can
|
||||||
|
* reset the owner). Send the link to the owner yourself, by a channel you
|
||||||
|
* know is theirs. Their current password keeps working until it is used.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const admin = await requirePlatformAdmin(req);
|
||||||
|
if (!admin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Business not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const business = await Business.findById(params.id).select("ownerId").lean();
|
||||||
|
if (!business) return NextResponse.json({ error: "Business not found" }, { status: 404 });
|
||||||
|
const owner = await User.findOne({ _id: business.ownerId, businessId: business._id });
|
||||||
|
if (!owner) return NextResponse.json({ error: "This business has no owner account" }, { status: 404 });
|
||||||
|
if (owner._id.toString() === admin.userId) {
|
||||||
|
return NextResponse.json({ error: "Change your own password from Change password instead" }, { status: 409 });
|
||||||
|
}
|
||||||
|
if (!owner.isActive) return NextResponse.json({ error: "The owner's account is deactivated" }, { status: 409 });
|
||||||
|
|
||||||
|
const purpose = invitePending(owner) ? "invite" : "reset";
|
||||||
|
const inviteUrl = issueInvite(owner, req, purpose);
|
||||||
|
await owner.save();
|
||||||
|
return NextResponse.json({ inviteUrl, purpose, email: owner.email, name: owner.name, expiresAt: owner.inviteExpiresAt });
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import { Business } from "@/lib/server/models/Business";
|
|||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { requirePlatformAdmin } from "@/lib/server/platform";
|
import { requirePlatformAdmin } from "@/lib/server/platform";
|
||||||
import { TIERS, type TierId } from "@/lib/tiers";
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
|
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -23,22 +24,26 @@ export async function GET(req: NextRequest) {
|
|||||||
const businesses = await Business.find({}).sort({ createdAt: -1 }).lean();
|
const businesses = await Business.find({}).sort({ createdAt: -1 }).lean();
|
||||||
|
|
||||||
// Usage counts per business in two grouped queries instead of N+1.
|
// Usage counts per business in two grouped queries instead of N+1.
|
||||||
const [userCounts, branchCounts] = await Promise.all([
|
const [userCounts, branchCounts] = await crossTenant("the platform console counts every business", () =>
|
||||||
User.aggregate<{ _id: unknown; count: number }>([
|
Promise.all([
|
||||||
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
User.aggregate<{ _id: unknown; count: number }>([{ $group: { _id: "$businessId", count: { $sum: 1 } } }]).exec(),
|
||||||
]),
|
Branch.aggregate<{ _id: unknown; count: number }>([
|
||||||
Branch.aggregate<{ _id: unknown; count: number }>([
|
{ $match: { isActive: { $ne: false } } },
|
||||||
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
||||||
]),
|
]).exec(),
|
||||||
]);
|
])
|
||||||
|
);
|
||||||
const userMap = new Map(userCounts.map((u) => [String(u._id), u.count]));
|
const userMap = new Map(userCounts.map((u) => [String(u._id), u.count]));
|
||||||
const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count]));
|
const branchMap = new Map(branchCounts.map((b) => [String(b._id), b.count]));
|
||||||
|
|
||||||
// Owner contact lookup.
|
// Owner contact lookup.
|
||||||
const ownerIds = businesses.map((b) => b.ownerId);
|
const ownerIds = businesses.map((b) => b.ownerId);
|
||||||
const owners = await User.find({ _id: { $in: ownerIds } })
|
const owners = await crossTenant("the platform console lists every business's owner", () =>
|
||||||
.select("name email phoneNumber")
|
User.find({ _id: { $in: ownerIds } })
|
||||||
.lean();
|
.select("name email phoneNumber")
|
||||||
|
.lean()
|
||||||
|
.exec()
|
||||||
|
);
|
||||||
const ownerMap = new Map(owners.map((o) => [String(o._id), o]));
|
const ownerMap = new Map(owners.map((o) => [String(o._id), o]));
|
||||||
|
|
||||||
const rows = businesses.map((b) => {
|
const rows = businesses.map((b) => {
|
||||||
@@ -58,7 +63,7 @@ export async function GET(req: NextRequest) {
|
|||||||
userCount: userMap.get(String(b._id)) ?? 0,
|
userCount: userMap.get(String(b._id)) ?? 0,
|
||||||
branchCount: branchMap.get(String(b._id)) ?? 0,
|
branchCount: branchMap.get(String(b._id)) ?? 0,
|
||||||
maxUsers: TIERS[tier].maxUsers,
|
maxUsers: TIERS[tier].maxUsers,
|
||||||
maxBranches: TIERS[tier].maxBranches,
|
maxBranches: effectiveMaxBranches(tier, (b.subscriptionStatus as SubscriptionStatus) ?? "TRIALING"),
|
||||||
owner: owner
|
owner: owner
|
||||||
? { name: owner.name, email: owner.email, phoneNumber: owner.phoneNumber }
|
? { name: owner.name, email: owner.email, phoneNumber: owner.phoneNumber }
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -2,24 +2,30 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { getAuth, requireRole } from "@/lib/server/auth";
|
import { authorize } from "@/lib/server/auth";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
|
import { PRODUCT_UNITS } from "@/lib/units";
|
||||||
|
import { labelTakenError } from "@/lib/server/products";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
label: z.string().trim().min(1).max(60).optional(),
|
label: z.string().trim().min(1).max(60).optional(),
|
||||||
sizeLiters: z.number().positive().max(1000).optional(),
|
sizeLiters: z.number().positive().max(1000).optional(),
|
||||||
unitPrice: z.number().nonnegative().optional(),
|
unit: z.enum(PRODUCT_UNITS).optional(),
|
||||||
|
unitPrice: z.number().positive("Price must be more than zero").optional(),
|
||||||
sortOrder: z.number().int().optional(),
|
sortOrder: z.number().int().optional(),
|
||||||
isActive: z.boolean().optional(),
|
isActive: z.boolean().optional(),
|
||||||
|
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
// Owner only — product prices apply to every branch.
|
||||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
const gate = authorize(req, ["SUPER_ADMIN"]);
|
||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!gate.ok) return gate.response;
|
||||||
}
|
const auth = gate.auth;
|
||||||
|
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Product not found" }, { status: 404 });
|
||||||
|
|
||||||
const json = await req.json().catch(() => null);
|
const json = await req.json().catch(() => null);
|
||||||
const parsed = updateSchema.safeParse(json);
|
const parsed = updateSchema.safeParse(json);
|
||||||
@@ -30,8 +36,13 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
|
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.data.label) {
|
||||||
|
const taken = await labelTakenError(auth.businessId, parsed.data.label, params.id);
|
||||||
|
if (taken) return NextResponse.json({ error: taken }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
const product = await Product.findOneAndUpdate(
|
const product = await Product.findOneAndUpdate(
|
||||||
{ _id: params.id, businessId: auth!.businessId },
|
{ _id: params.id, businessId: auth.businessId },
|
||||||
parsed.data,
|
parsed.data,
|
||||||
{ new: true }
|
{ new: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +1,66 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
||||||
import { getAuth, requireRole } from "@/lib/server/auth";
|
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { PRODUCT_UNITS } from "@/lib/units";
|
||||||
|
import { labelTakenError } from "@/lib/server/products";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/products — the tenant's active catalogue (?includeInactive=1
|
||||||
|
// adds deactivated products, for managers and owners). For branch-scoped
|
||||||
|
// staff the price returned is the price THEIR branch charges (the branch
|
||||||
|
// override when one exists), so the terminal shows exactly what the
|
||||||
|
// server will record.
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean();
|
const includeInactive =
|
||||||
|
req.nextUrl.searchParams.get("includeInactive") === "1" && MANAGERS.includes(auth.role);
|
||||||
|
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, isActive: true };
|
||||||
|
const products = await Product.find(filter).sort({ sortOrder: 1 }).lean();
|
||||||
|
// The catalogue view (includeInactive) shows default prices; the till's
|
||||||
|
// view shows what this branch actually charges.
|
||||||
|
if (auth.branchId && !includeInactive) {
|
||||||
|
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
|
||||||
|
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
|
||||||
|
for (const p of products) {
|
||||||
|
p.unitPrice = priceByProduct.get(p._id.toString()) ?? p.unitPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
return NextResponse.json({ products });
|
return NextResponse.json({ products });
|
||||||
}
|
}
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
label: z.string().trim().min(1).max(60),
|
label: z.string().trim().min(1).max(60),
|
||||||
sizeLiters: z.number().positive().max(1000),
|
sizeLiters: z.number().positive().max(1000),
|
||||||
unit: z.string().trim().min(1).max(12).optional(),
|
unit: z.enum(PRODUCT_UNITS).optional(),
|
||||||
category: z.string().trim().min(1).max(40).optional(),
|
category: z.string().trim().min(1).max(40).optional(),
|
||||||
unitPrice: z.number().nonnegative(),
|
unitPrice: z.number().positive("Price must be more than zero"),
|
||||||
sortOrder: z.number().int().optional(),
|
sortOrder: z.number().int().optional(),
|
||||||
|
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/products — owner only: the catalogue and its prices apply to
|
||||||
|
// every branch, so a branch manager must not be able to change them.
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, ["SUPER_ADMIN"]);
|
||||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
if (!gate.ok) return gate.response;
|
||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
const auth = gate.auth;
|
||||||
}
|
|
||||||
|
|
||||||
const json = await req.json().catch(() => null);
|
const json = await req.json().catch(() => null);
|
||||||
const parsed = createSchema.safeParse(json);
|
const parsed = createSchema.safeParse(json);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const product = await Product.create({ ...parsed.data, businessId: auth!.businessId });
|
const taken = await labelTakenError(auth.businessId, parsed.data.label);
|
||||||
|
if (taken) return NextResponse.json({ error: taken }, { status: 409 });
|
||||||
|
const product = await Product.create({ ...parsed.data, businessId: auth.businessId });
|
||||||
return NextResponse.json({ product }, { status: 201 });
|
return NextResponse.json({ product }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { z } from "zod";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Purchase } from "@/lib/server/models/Purchase";
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
import { recordMovement } from "@/lib/server/stock";
|
||||||
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -12,11 +15,14 @@ export const dynamic = "force-dynamic";
|
|||||||
// GET /api/purchases?branchId=&from=&to=
|
// GET /api/purchases?branchId=&from=&to=
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, MANAGERS);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
const scope = branchParam(auth, url.searchParams.get("branchId"));
|
||||||
|
if (!scope.ok) return scope.response;
|
||||||
|
const scopeBranch = scope.branchId;
|
||||||
|
|
||||||
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
if (scopeBranch) filter.branchId = scopeBranch;
|
if (scopeBranch) filter.branchId = scopeBranch;
|
||||||
@@ -31,8 +37,8 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const purchases = await Purchase.find(filter)
|
const purchases = await Purchase.find(filter)
|
||||||
.populate("branchId", "branchName")
|
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
|
||||||
.populate("recordedBy", "name")
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(500)
|
.limit(500)
|
||||||
.lean();
|
.lean();
|
||||||
@@ -41,9 +47,9 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
branchId: z.string().trim().min(1).optional(),
|
branchId: objectId.optional(),
|
||||||
supplierName: z.string().trim().min(1).max(120),
|
supplierName: z.string().trim().min(1).max(120),
|
||||||
productId: z.string().trim().min(1).optional(),
|
productId: objectId.optional(),
|
||||||
productLabel: z.string().trim().min(1).max(120).optional(),
|
productLabel: z.string().trim().min(1).max(120).optional(),
|
||||||
quantity: z.number().int().positive().max(1_000_000),
|
quantity: z.number().int().positive().max(1_000_000),
|
||||||
unitCost: z.number().nonnegative().max(100_000_000),
|
unitCost: z.number().nonnegative().max(100_000_000),
|
||||||
@@ -53,12 +59,14 @@ const createSchema = z.object({
|
|||||||
|
|
||||||
// POST /api/purchases — records a supplier purchase. The buying price is
|
// POST /api/purchases — records a supplier purchase. The buying price is
|
||||||
// snapshotted (unitCost/totalCost) so historical records never shift when
|
// snapshotted (unitCost/totalCost) so historical records never shift when
|
||||||
// product prices change later. A completed purchase conceptually increases
|
// product prices change later. A purchase of a catalogue product adds its
|
||||||
// branch stock.
|
// quantity to the branch's stock ledger; a free-text item (no productId)
|
||||||
|
// is recorded as a cost only.
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, MANAGERS);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
const json = await req.json().catch(() => null);
|
const json = await req.json().catch(() => null);
|
||||||
const parsed = createSchema.safeParse(json);
|
const parsed = createSchema.safeParse(json);
|
||||||
@@ -84,7 +92,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The target branch MUST belong to this tenant.
|
// The target branch MUST belong to this tenant.
|
||||||
const branchDoc = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
const branchDoc = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
||||||
if (!branchDoc) {
|
if (!branchDoc) {
|
||||||
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -120,5 +128,18 @@ export async function POST(req: NextRequest) {
|
|||||||
recordedBy: auth.sub,
|
recordedBy: auth.sub,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (productId) {
|
||||||
|
await recordMovement({
|
||||||
|
businessId: auth.businessId,
|
||||||
|
branchId: branchId!,
|
||||||
|
productId,
|
||||||
|
type: "PURCHASE",
|
||||||
|
quantity: parsed.data.quantity,
|
||||||
|
refId: purchase._id.toString(),
|
||||||
|
note: `From ${parsed.data.supplierName}`,
|
||||||
|
recordedBy: auth.sub,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ purchase }, { status: 201 });
|
return NextResponse.json({ purchase }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,148 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import mongoose from "mongoose";
|
import mongoose, { Types } from "mongoose";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
import { Purchase } from "@/lib/server/models/Purchase";
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
import { Expense } from "@/lib/server/models/Expense";
|
import { Expense } from "@/lib/server/models/Expense";
|
||||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi } from "@/lib/server/businessDay";
|
||||||
|
import { costOfGoodsSold as fifoCost } from "@/lib/server/costing";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// Management / profit overview — NOT full accounting.
|
/**
|
||||||
// Revenue = sum of SUCCESS sales in range
|
* GET /api/reports/profit?branchId=&fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD
|
||||||
// Cost of Goods= sum of purchase cost in range (stock bought in)
|
*
|
||||||
// Gross Profit = Revenue - Cost of Goods
|
* Management overview, NOT accounting (full accounting is out of the PRD's scope).
|
||||||
// Expenses = sum of operating expenses in range
|
* Revenue SUCCESS sales in the period
|
||||||
// Net Profit = Gross Profit - Expenses
|
* Cost of goods sold first-in-first-out per branch: each unit sold costs
|
||||||
|
* what that branch paid for its oldest stock on hand
|
||||||
|
* (see src/lib/server/costing.ts)
|
||||||
|
* Gross profit revenue - cost of goods sold
|
||||||
|
* Expenses operating expenses in the period
|
||||||
|
* Stock losses cost of stock written off by counts and adjustments
|
||||||
|
* Net profit gross profit - expenses - stock losses
|
||||||
|
* Shown separately, because buying stock is not the same as using it:
|
||||||
|
* Stock purchased cash spent on stock in the period
|
||||||
|
* Cash surplus revenue - stock purchased - expenses
|
||||||
|
*
|
||||||
|
* Dates are Nairobi business days, inclusive. `from`/`to` (ISO instants)
|
||||||
|
* are still accepted for older clients.
|
||||||
|
*/
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
// Revenue, costs and profit are owner/manager information, never a cashier's.
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
const gate = authorize(req, MANAGERS);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const branchId = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
const scope = branchParam(auth, url.searchParams.get("branchId"));
|
||||||
|
if (!scope.ok) return scope.response;
|
||||||
|
const branchId = scope.branchId;
|
||||||
|
|
||||||
// Default range: start of the current month -> now.
|
const range = parseRange(url.searchParams);
|
||||||
const now = new Date();
|
if ("error" in range) return badRequest(range.error);
|
||||||
const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
const { from, to } = range;
|
||||||
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : defaultFrom;
|
|
||||||
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
|
|
||||||
|
|
||||||
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
|
||||||
const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
|
const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
|
||||||
const dateMatch = { createdAt: { $gte: from, $lte: to } };
|
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
||||||
|
const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) };
|
||||||
|
|
||||||
const salesMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId, paymentStatus: "SUCCESS" };
|
const [salesByProduct, cost, purchaseAgg, expenseAgg] = await Promise.all([
|
||||||
if (branchObjId) salesMatch.branchId = branchObjId;
|
Transaction.aggregate<{
|
||||||
|
_id: Types.ObjectId;
|
||||||
const costMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId };
|
revenue: number;
|
||||||
if (branchObjId) costMatch.branchId = branchObjId;
|
cashTotal: number;
|
||||||
|
mpesaTotal: number;
|
||||||
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([
|
units: number;
|
||||||
Transaction.aggregate([
|
lines: number;
|
||||||
{ $match: salesMatch },
|
}>([
|
||||||
|
{ $match: { ...inPeriod, paymentStatus: "SUCCESS" } },
|
||||||
{
|
{
|
||||||
$group: {
|
$group: {
|
||||||
_id: null,
|
_id: "$productId",
|
||||||
revenue: { $sum: "$amountTotal" },
|
revenue: { $sum: "$amountTotal" },
|
||||||
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
|
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
|
||||||
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
|
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
|
||||||
salesCount: { $sum: 1 },
|
units: { $sum: "$quantity" },
|
||||||
|
lines: { $sum: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
Purchase.aggregate([
|
fifoCost(auth.businessId, { from, to, branchId }),
|
||||||
{ $match: costMatch },
|
Purchase.aggregate<{ total: number; count: number }>([
|
||||||
{ $group: { _id: null, costOfGoods: { $sum: "$totalCost" }, purchaseCount: { $sum: 1 } } },
|
{ $match: inPeriod },
|
||||||
|
{ $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } },
|
||||||
]),
|
]),
|
||||||
Expense.aggregate([
|
Expense.aggregate<{ total: number; count: number }>([
|
||||||
{ $match: costMatch },
|
{ $match: inPeriod },
|
||||||
{ $group: { _id: null, expenses: { $sum: "$amount" }, expenseCount: { $sum: 1 } } },
|
{ $group: { _id: null, total: { $sum: "$amount" }, count: { $sum: 1 } } },
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const revenue = salesAgg[0]?.revenue ?? 0;
|
let revenue = 0, cashTotal = 0, mpesaTotal = 0, salesCount = 0, unitsSold = 0;
|
||||||
const costOfGoods = purchaseAgg[0]?.costOfGoods ?? 0;
|
for (const s of salesByProduct) {
|
||||||
const expenses = expenseAgg[0]?.expenses ?? 0;
|
revenue += s.revenue;
|
||||||
const grossProfit = revenue - costOfGoods;
|
cashTotal += s.cashTotal;
|
||||||
const netProfit = grossProfit - expenses;
|
mpesaTotal += s.mpesaTotal;
|
||||||
|
salesCount += s.lines;
|
||||||
|
unitsSold += s.units;
|
||||||
|
}
|
||||||
|
const costOfGoodsSold = cost.costOfGoodsSold;
|
||||||
|
// Sales recorded before the stock ledger existed have no stock movement,
|
||||||
|
// so FIFO cannot cost them: count them as uncosted too.
|
||||||
|
const unitsWithoutCost = Math.max(cost.unitsWithoutCost, unitsSold - cost.unitsCosted);
|
||||||
|
|
||||||
|
const stockPurchases = purchaseAgg[0]?.total ?? 0;
|
||||||
|
const expenses = expenseAgg[0]?.total ?? 0;
|
||||||
|
const grossProfit = Math.round((revenue - costOfGoodsSold) * 100) / 100;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
scope: branchId ? "branch" : "global",
|
scope: branchId ? "branch" : "global",
|
||||||
range: { from: from.toISOString(), to: to.toISOString() },
|
range: { from: from.toISOString(), to: to.toISOString() },
|
||||||
revenue,
|
revenue,
|
||||||
cashTotal: salesAgg[0]?.cashTotal ?? 0,
|
cashTotal,
|
||||||
mpesaTotal: salesAgg[0]?.mpesaTotal ?? 0,
|
mpesaTotal,
|
||||||
salesCount: salesAgg[0]?.salesCount ?? 0,
|
salesCount,
|
||||||
costOfGoods,
|
unitsSold,
|
||||||
purchaseCount: purchaseAgg[0]?.purchaseCount ?? 0,
|
costOfGoodsSold,
|
||||||
|
costMethod: "FIFO per branch",
|
||||||
|
// Units sold whose cost is unknown (no purchase price for that stock on
|
||||||
|
// record): gross profit overstates by their cost.
|
||||||
|
unitsWithoutCost,
|
||||||
|
stockLosses: cost.stockLosses,
|
||||||
grossProfit,
|
grossProfit,
|
||||||
expenses,
|
expenses,
|
||||||
expenseCount: expenseAgg[0]?.expenseCount ?? 0,
|
expenseCount: expenseAgg[0]?.count ?? 0,
|
||||||
netProfit,
|
netProfit: Math.round((grossProfit - expenses - cost.stockLosses) * 100) / 100,
|
||||||
|
stockPurchases,
|
||||||
|
purchaseCount: purchaseAgg[0]?.count ?? 0,
|
||||||
|
cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The half-open UTC range [from, to) for the request, or a validation error. */
|
||||||
|
function parseRange(params: URLSearchParams): { from: Date; to: Date } | { error: string } {
|
||||||
|
const fromDate = params.get("fromDate");
|
||||||
|
const toDate = params.get("toDate");
|
||||||
|
let from: Date;
|
||||||
|
let to: Date;
|
||||||
|
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
if ((fromDate && !isBusinessDate(fromDate)) || (toDate && !isBusinessDate(toDate))) {
|
||||||
|
return { error: "Dates must be YYYY-MM-DD" };
|
||||||
|
}
|
||||||
|
from = fromDate ? dayRangeNairobi(fromDate).start : startOfMonthNairobi();
|
||||||
|
to = toDate ? dayRangeNairobi(toDate).end : new Date();
|
||||||
|
} else {
|
||||||
|
from = params.get("from") ? new Date(params.get("from")!) : startOfMonthNairobi();
|
||||||
|
to = params.get("to") ? new Date(params.get("to")!) : new Date();
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return { error: "Invalid from/to date" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reversed period must not look like a period with no activity.
|
||||||
|
if (from >= to) return { error: "The start date must be on or before the end date." };
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
import { StockMovement } from "@/lib/server/models/StockMovement";
|
||||||
|
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
import { clientRefId, recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const adjustmentSchema = z.object({
|
||||||
|
clientUuid: z.string().uuid(),
|
||||||
|
branchId: objectId.optional(), // owners must give one; managers default to their own branch
|
||||||
|
productId: objectId,
|
||||||
|
// COUNT: "we physically counted N", so the ledger is corrected to N.
|
||||||
|
// DELTA: "add or remove N", e.g. opening stock, breakage, or a delivery
|
||||||
|
// that was not recorded as a purchase.
|
||||||
|
mode: z.enum(["COUNT", "DELTA"]),
|
||||||
|
quantity: z.number().int().min(-1_000_000).max(1_000_000),
|
||||||
|
note: z.string().trim().max(300).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/stock/adjustments — managers (own branch) and owners (any branch).
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, MANAGERS);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
const parsed = adjustmentSchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = parsed.data;
|
||||||
|
if (data.mode === "COUNT" && data.quantity < 0) {
|
||||||
|
return NextResponse.json({ error: "A counted quantity cannot be negative" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let branchId: string | null;
|
||||||
|
if (auth.role === "SUPER_ADMIN") {
|
||||||
|
branchId = data.branchId ?? null;
|
||||||
|
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
||||||
|
} else {
|
||||||
|
branchId = auth.branchId;
|
||||||
|
if (data.branchId && data.branchId !== auth.branchId) {
|
||||||
|
return NextResponse.json({ error: "Cannot adjust stock for another branch" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!branchId) return NextResponse.json({ error: "No branch assignment for this account" }, { status: 403 });
|
||||||
|
|
||||||
|
const [branch, product] = await Promise.all([
|
||||||
|
Branch.exists({ _id: branchId, businessId: auth.businessId }),
|
||||||
|
Product.exists({ _id: data.productId, businessId: auth.businessId }),
|
||||||
|
]);
|
||||||
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
|
// A retried submit with the same clientUuid is a no-op. A count reads the
|
||||||
|
// balance to work out its correction, so it runs under the stock lock:
|
||||||
|
// a transfer cannot slip in between the read and the write.
|
||||||
|
const refId = clientRefId(auth.businessId, data.clientUuid);
|
||||||
|
const lockedBranch = branchId;
|
||||||
|
try {
|
||||||
|
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
|
||||||
|
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId, businessId: auth.businessId });
|
||||||
|
if (!already) {
|
||||||
|
const before = await stockOf(auth.businessId, lockedBranch, data.productId);
|
||||||
|
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
|
||||||
|
if (delta !== 0) {
|
||||||
|
const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment";
|
||||||
|
await recordMovement({
|
||||||
|
businessId: auth.businessId,
|
||||||
|
branchId: lockedBranch,
|
||||||
|
productId: data.productId,
|
||||||
|
type: "ADJUSTMENT",
|
||||||
|
quantity: delta,
|
||||||
|
refId,
|
||||||
|
note: data.note ? `${label}. ${data.note}` : label,
|
||||||
|
recordedBy: auth.sub,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stockOf(auth.businessId, lockedBranch, data.productId);
|
||||||
|
});
|
||||||
|
return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StockBusyError) return NextResponse.json({ error: err.message }, { status: 409 });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { StockMovement } from "@/lib/server/models/StockMovement";
|
||||||
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/stock/movements?branchId=&limit= — recent ledger entries, newest first.
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, MANAGERS);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
|
if (!scope.ok) return scope.response;
|
||||||
|
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
|
||||||
|
|
||||||
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
|
if (scope.branchId) filter.branchId = scope.branchId;
|
||||||
|
|
||||||
|
const movements = await StockMovement.find(filter)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
|
||||||
|
.populate(scopedPopulate("productId", "label unit", auth.businessId))
|
||||||
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
|
.lean();
|
||||||
|
return NextResponse.json({ movements });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { authorize, branchParam } from "@/lib/server/auth";
|
||||||
|
import { getStockLevels } from "@/lib/server/stock";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/stock?branchId= — current stock per branch and product.
|
||||||
|
// Owners see every branch (or one, via branchId); everyone else sees only
|
||||||
|
// their own branch.
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
|
if (!scope.ok) return scope.response;
|
||||||
|
if (auth.role !== "SUPER_ADMIN" && !scope.branchId) return NextResponse.json({ levels: [] });
|
||||||
|
|
||||||
|
const levels = await getStockLevels(auth.businessId, scope.branchId);
|
||||||
|
return NextResponse.json({ levels });
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
|
||||||
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/stock/transfers?branchId= — recent transfers into or out of the branch(es) in scope.
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, MANAGERS);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
|
if (!scope.ok) return scope.response;
|
||||||
|
|
||||||
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
|
if (scope.branchId) filter.$or = [{ fromBranchId: scope.branchId }, { toBranchId: scope.branchId }];
|
||||||
|
|
||||||
|
const transfers = await StockTransfer.find(filter)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(100)
|
||||||
|
.populate(scopedPopulate("fromBranchId", "branchName", auth.businessId))
|
||||||
|
.populate(scopedPopulate("toBranchId", "branchName", auth.businessId))
|
||||||
|
.populate(scopedPopulate("productId", "label unit", auth.businessId))
|
||||||
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
|
.lean();
|
||||||
|
return NextResponse.json({ transfers });
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferSchema = z.object({
|
||||||
|
clientUuid: z.string().uuid(),
|
||||||
|
fromBranchId: objectId.optional(), // owners must give one; managers send from their own branch
|
||||||
|
toBranchId: objectId,
|
||||||
|
productId: objectId,
|
||||||
|
quantity: z.number().int().min(1).max(1_000_000),
|
||||||
|
note: z.string().trim().max(300).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Both ledger sides of a transfer. Idempotent, so it is safe to re-run on a retry. */
|
||||||
|
async function applyTransfer(t: IStockTransfer) {
|
||||||
|
const common = {
|
||||||
|
businessId: t.businessId,
|
||||||
|
productId: t.productId,
|
||||||
|
refId: t._id.toString(),
|
||||||
|
recordedBy: t.recordedBy,
|
||||||
|
note: t.note,
|
||||||
|
};
|
||||||
|
await recordMovement({ ...common, branchId: t.fromBranchId, type: "TRANSFER_OUT", quantity: -t.quantity });
|
||||||
|
await recordMovement({ ...common, branchId: t.toBranchId, type: "TRANSFER_IN", quantity: t.quantity });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/stock/transfers — move stock from one branch to another.
|
||||||
|
// Owners: between any two branches. Managers: out of their own branch only.
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, MANAGERS);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
const parsed = transferSchema.safeParse(await req.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
// A retried submit returns the transfer that already happened.
|
||||||
|
const existing = await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid });
|
||||||
|
if (existing) {
|
||||||
|
await applyTransfer(existing);
|
||||||
|
return NextResponse.json({ transfer: existing }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromBranchId: string | null;
|
||||||
|
if (auth.role === "SUPER_ADMIN") {
|
||||||
|
fromBranchId = data.fromBranchId ?? null;
|
||||||
|
if (!fromBranchId) return NextResponse.json({ error: "fromBranchId is required" }, { status: 400 });
|
||||||
|
} else {
|
||||||
|
fromBranchId = auth.branchId;
|
||||||
|
if (data.fromBranchId && data.fromBranchId !== auth.branchId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Branch managers can only transfer stock out of their own branch" },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fromBranchId) return NextResponse.json({ error: "No branch assignment for this account" }, { status: 403 });
|
||||||
|
if (fromBranchId === data.toBranchId) {
|
||||||
|
return NextResponse.json({ error: "Choose two different branches" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [branchCount, product] = await Promise.all([
|
||||||
|
Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId, ...ACTIVE_BRANCH }),
|
||||||
|
Product.exists({ _id: data.productId, businessId: auth.businessId }),
|
||||||
|
]);
|
||||||
|
if (branchCount !== 2) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
|
||||||
|
|
||||||
|
// Transfers are made online by a manager, so unlike offline sales they
|
||||||
|
// can be refused when the sending branch does not have the stock. The
|
||||||
|
// balance check and the movements run under the sending branch's stock
|
||||||
|
// lock, so two simultaneous transfers cannot both spend the same units.
|
||||||
|
const sendingBranch = fromBranchId;
|
||||||
|
try {
|
||||||
|
return await withStockLock(auth.businessId, sendingBranch, data.productId, async () => {
|
||||||
|
// An identical submit may have completed while we waited for the lock.
|
||||||
|
const done = await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid });
|
||||||
|
if (done) {
|
||||||
|
await applyTransfer(done);
|
||||||
|
return NextResponse.json({ transfer: done }, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = await stockOf(auth.businessId, sendingBranch, data.productId);
|
||||||
|
if (available < data.quantity) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Only ${Math.max(available, 0)} in stock at the sending branch`, available },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let transfer: IStockTransfer;
|
||||||
|
try {
|
||||||
|
transfer = await StockTransfer.create({
|
||||||
|
businessId: auth.businessId,
|
||||||
|
fromBranchId: sendingBranch,
|
||||||
|
toBranchId: data.toBranchId,
|
||||||
|
productId: data.productId,
|
||||||
|
quantity: data.quantity,
|
||||||
|
clientUuid: data.clientUuid,
|
||||||
|
note: data.note ?? null,
|
||||||
|
recordedBy: auth.sub,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Defensive: the unique clientUuid index still guards double submits.
|
||||||
|
if ((err as { code?: number })?.code !== 11000) throw err;
|
||||||
|
transfer = (await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }))!;
|
||||||
|
}
|
||||||
|
await applyTransfer(transfer);
|
||||||
|
return NextResponse.json({ transfer }, { status: 201 });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof StockBusyError) return NextResponse.json({ error: err.message }, { status: 409 });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,67 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction, ITransaction } from "@/lib/server/models/Transaction";
|
||||||
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
||||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { authorize } from "@/lib/server/auth";
|
||||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||||
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
import { recordSaleMovement } from "@/lib/server/stock";
|
||||||
|
import { MAX_LINES_PER_SALE, MAX_SALES_PER_REQUEST, type SaleResult } from "@/lib/syncProtocol";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
const saleSchema = z.object({
|
const lineSchema = z.object({
|
||||||
clientUuid: z.string().uuid(),
|
productId: objectId,
|
||||||
customerName: z.string().trim().max(120).optional(),
|
|
||||||
customerPhone: z.string().trim().min(9).max(15),
|
|
||||||
productId: z.string().min(1),
|
|
||||||
quantity: z.number().int().min(1).max(500),
|
quantity: z.number().int().min(1).max(500),
|
||||||
clientSubmittedAt: z.string().datetime().optional(),
|
unitPrice: z.number().nonnegative().optional(), // what the device showed; informational
|
||||||
});
|
});
|
||||||
const bodySchema = z.object({ sales: z.array(saleSchema).min(1).max(100) });
|
|
||||||
|
|
||||||
|
const saleSchema = z.preprocess(
|
||||||
|
// Older clients sent one product per sale at the top level.
|
||||||
|
(raw) => {
|
||||||
|
const r = raw as Record<string, unknown> | null;
|
||||||
|
if (r && !Array.isArray(r.lines) && r.productId) {
|
||||||
|
return { ...r, lines: [{ productId: r.productId, quantity: r.quantity }] };
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
},
|
||||||
|
z.object({
|
||||||
|
clientUuid: z.string().uuid(),
|
||||||
|
staffId: objectId.optional(), // who recorded it on the device
|
||||||
|
branchId: objectId.optional(), // where it was recorded
|
||||||
|
customerName: z.string().trim().max(120).optional(),
|
||||||
|
customerPhone: z.string().trim().max(20).optional(),
|
||||||
|
clientSubmittedAt: z.string().datetime().optional(),
|
||||||
|
lines: z.array(lineSchema).min(1).max(MAX_LINES_PER_SALE),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
type SaleInput = z.infer<typeof saleSchema>;
|
||||||
|
|
||||||
|
const bodySchema = z.object({ sales: z.array(z.unknown()).min(1).max(MAX_SALES_PER_REQUEST) });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/transactions/cash — record cash sales, one or many, live or
|
||||||
|
* replayed from the device's offline queue.
|
||||||
|
*
|
||||||
|
* Every sale gets its own result so the device knows exactly what to do
|
||||||
|
* with each queued row:
|
||||||
|
* saved newly recorded -> mark synced
|
||||||
|
* duplicate already recorded earlier -> mark synced (this is a replay)
|
||||||
|
* rejected can never be recorded -> stop retrying, show the cashier
|
||||||
|
* One bad sale never blocks the others in the batch.
|
||||||
|
*
|
||||||
|
* Idempotency: each line's clientUuid is `${saleUuid}:${index}` and is
|
||||||
|
* unique in the database; the stock movement for each line is keyed on
|
||||||
|
* the transaction id. Replaying a sale any number of times records it,
|
||||||
|
* and deducts its stock, exactly once.
|
||||||
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, ["DELIVERY", "BRANCH_MANAGER"]);
|
||||||
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
|
if (!gate.ok) return gate.response;
|
||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
const auth = gate.auth;
|
||||||
}
|
|
||||||
|
|
||||||
const json = await req.json().catch(() => null);
|
const json = await req.json().catch(() => null);
|
||||||
const normalized = Array.isArray(json?.sales) ? json : { sales: [json] };
|
const normalized = Array.isArray(json?.sales) ? json : { sales: [json] };
|
||||||
@@ -31,61 +69,122 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (!auth.branchId) {
|
||||||
const branchId = resolveBranchScope(auth!);
|
|
||||||
if (!branchId) {
|
|
||||||
return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 });
|
return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sales } = parsed.data;
|
const sales: SaleInput[] = [];
|
||||||
const productIds = Array.from(new Set(sales.map((s) => s.productId)));
|
const results: SaleResult[] = [];
|
||||||
const products = await Product.find({ _id: { $in: productIds }, businessId: auth!.businessId }).lean();
|
for (const raw of parsed.data.sales) {
|
||||||
const overrides = await BranchPricing.find({ branchId, productId: { $in: productIds }, businessId: auth!.businessId }).lean();
|
const sale = saleSchema.safeParse(raw);
|
||||||
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
|
if (sale.success) {
|
||||||
const overrideMap = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
|
sales.push(sale.data);
|
||||||
|
} else {
|
||||||
const rejected: { clientUuid: string; error: string }[] = [];
|
const clientUuid = String((raw as { clientUuid?: unknown })?.clientUuid ?? "");
|
||||||
const inserted: any[] = [];
|
results.push({ clientUuid, status: "rejected", error: sale.error.issues[0]?.message ?? "Invalid sale" });
|
||||||
|
|
||||||
for (const sale of sales) {
|
|
||||||
let phone: string;
|
|
||||||
try {
|
|
||||||
phone = normalizeKenyanPhone(sale.customerPhone);
|
|
||||||
} catch (e: any) {
|
|
||||||
rejected.push({ clientUuid: sale.clientUuid, error: e.message });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const product = productMap.get(sale.productId);
|
|
||||||
if (!product || !product.isActive) {
|
|
||||||
rejected.push({ clientUuid: sale.clientUuid, error: "Product not available" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const unitPrice = overrideMap.get(sale.productId) ?? product.unitPrice;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const created = await Transaction.create({
|
|
||||||
businessId: auth!.businessId,
|
|
||||||
branchId,
|
|
||||||
staffId: auth!.sub,
|
|
||||||
customerName: sale.customerName ?? null,
|
|
||||||
customerPhone: phone,
|
|
||||||
productId: product._id,
|
|
||||||
sizeLiters: product.sizeLiters,
|
|
||||||
quantity: sale.quantity,
|
|
||||||
unitPrice,
|
|
||||||
amountTotal: Math.round(sale.quantity * unitPrice * 100) / 100,
|
|
||||||
paymentMethod: "CASH",
|
|
||||||
paymentStatus: "SUCCESS",
|
|
||||||
clientUuid: sale.clientUuid,
|
|
||||||
syncedFromOffline: !!sale.clientSubmittedAt,
|
|
||||||
clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null,
|
|
||||||
});
|
|
||||||
inserted.push(created);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === 11000) continue;
|
|
||||||
rejected.push({ clientUuid: sale.clientUuid, error: "Could not save this sale" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ inserted, rejected });
|
// Tenant data needed to price every line in the batch, in three queries.
|
||||||
|
const productIds = Array.from(new Set(sales.flatMap((s) => s.lines.map((l) => l.productId))));
|
||||||
|
const branchIds = Array.from(new Set([auth.branchId, ...sales.map((s) => s.branchId ?? auth.branchId!)]));
|
||||||
|
const [products, overrides, branches] = await Promise.all([
|
||||||
|
Product.find({ _id: { $in: productIds }, businessId: auth.businessId }).lean(),
|
||||||
|
BranchPricing.find({ branchId: { $in: branchIds }, productId: { $in: productIds }, businessId: auth.businessId }).lean(),
|
||||||
|
Branch.find({ _id: { $in: branchIds }, businessId: auth.businessId }).select("_id").lean(),
|
||||||
|
]);
|
||||||
|
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
|
||||||
|
const overrideMap = new Map(overrides.map((o) => [`${o.branchId}:${o.productId}`, o.unitPrice]));
|
||||||
|
const tenantBranches = new Set(branches.map((b) => b._id.toString()));
|
||||||
|
|
||||||
|
for (const sale of sales) {
|
||||||
|
results.push(await recordSale(sale));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordSale(sale: SaleInput): Promise<SaleResult> {
|
||||||
|
const reject = (error: string): SaleResult => ({ clientUuid: sale.clientUuid, status: "rejected", error });
|
||||||
|
|
||||||
|
// A queued sale belongs to the cashier who made it. The device only
|
||||||
|
// sends its signed-in user's rows; anything else is refused rather than
|
||||||
|
// credited to the wrong person.
|
||||||
|
if (sale.staffId && sale.staffId !== auth.sub) {
|
||||||
|
return reject("This sale was recorded by a different user. Sign in as them to sync it.");
|
||||||
|
}
|
||||||
|
// The sale stays with the branch where it happened, even if the
|
||||||
|
// cashier has since been moved — as long as that branch is in this business.
|
||||||
|
const branchId = sale.branchId ?? auth.branchId!;
|
||||||
|
if (!tenantBranches.has(branchId)) return reject("This sale's branch is not part of your business.");
|
||||||
|
|
||||||
|
for (const line of sale.lines) {
|
||||||
|
if (!productMap.has(line.productId)) return reject("A product in this sale no longer exists.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bad phone number must not lose a sale that physically happened.
|
||||||
|
let customerPhone: string | null = null;
|
||||||
|
if (sale.customerPhone) {
|
||||||
|
try {
|
||||||
|
customerPhone = normalizeKenyanPhone(sale.customerPhone);
|
||||||
|
} catch {
|
||||||
|
customerPhone = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let newLines = 0;
|
||||||
|
let amountTotal = 0;
|
||||||
|
let priceMismatch = false;
|
||||||
|
for (const [index, line] of sale.lines.entries()) {
|
||||||
|
// A product deactivated while the device was offline is still
|
||||||
|
// recorded at its price: the sale already happened.
|
||||||
|
const product = productMap.get(line.productId)!;
|
||||||
|
const unitPrice = overrideMap.get(`${branchId}:${line.productId}`) ?? product.unitPrice;
|
||||||
|
const lineTotal = Math.round(line.quantity * unitPrice * 100) / 100;
|
||||||
|
const mismatch = line.unitPrice !== undefined && line.unitPrice !== unitPrice;
|
||||||
|
|
||||||
|
let txn: ITransaction;
|
||||||
|
try {
|
||||||
|
txn = await Transaction.create({
|
||||||
|
businessId: auth.businessId,
|
||||||
|
branchId,
|
||||||
|
staffId: auth.sub,
|
||||||
|
customerName: sale.customerName || null,
|
||||||
|
customerPhone,
|
||||||
|
productId: product._id,
|
||||||
|
sizeLiters: product.sizeLiters,
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitPrice,
|
||||||
|
amountTotal: lineTotal,
|
||||||
|
paymentMethod: "CASH",
|
||||||
|
paymentStatus: "SUCCESS",
|
||||||
|
clientUuid: `${sale.clientUuid}:${index}`,
|
||||||
|
saleId: sale.clientUuid,
|
||||||
|
lineIndex: index,
|
||||||
|
syncedFromOffline: !!sale.clientSubmittedAt,
|
||||||
|
clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null,
|
||||||
|
clientUnitPrice: line.unitPrice ?? null,
|
||||||
|
priceMismatch: mismatch,
|
||||||
|
});
|
||||||
|
newLines += 1;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as { code?: number })?.code !== 11000) throw err;
|
||||||
|
// Already recorded by an earlier attempt: reuse it.
|
||||||
|
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}`, businessId: auth.businessId });
|
||||||
|
if (!existing) return reject("This sale id is already in use.");
|
||||||
|
txn = existing;
|
||||||
|
}
|
||||||
|
// Idempotent, and also repairs a line whose stock movement was not
|
||||||
|
// written because an earlier attempt failed midway.
|
||||||
|
await recordSaleMovement(txn);
|
||||||
|
amountTotal += txn.amountTotal;
|
||||||
|
priceMismatch = priceMismatch || txn.priceMismatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientUuid: sale.clientUuid,
|
||||||
|
status: newLines === 0 ? "duplicate" : "saved",
|
||||||
|
amountTotal: Math.round(amountTotal * 100) / 100,
|
||||||
|
priceMismatch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ results });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { Transaction } from "@/lib/server/models/Transaction";
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
import { authorize, branchParam } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
|
import { startOfTodayNairobi } from "@/lib/server/businessDay";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req);
|
||||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
|
if (!scope.ok) return scope.response;
|
||||||
|
const branchId = scope.branchId;
|
||||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
|
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
|
||||||
|
|
||||||
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
if (branchId) filter.branchId = branchId;
|
if (branchId) filter.branchId = branchId;
|
||||||
|
|
||||||
const startOfDay = new Date();
|
filter.createdAt = { $gte: startOfTodayNairobi() };
|
||||||
startOfDay.setHours(0, 0, 0, 0);
|
|
||||||
filter.createdAt = { $gte: startOfDay };
|
|
||||||
|
|
||||||
const transactions = await Transaction.find(filter)
|
const transactions = await Transaction.find(filter)
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.populate("branchId", "branchName")
|
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
return NextResponse.json({ transactions });
|
return NextResponse.json({ transactions });
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { invitePending, issueInvite } from "@/lib/server/invites";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/users/:id/invite — a one-time password link for a staff member:
|
||||||
|
* - an INVITE link while they have not set a password yet (lost or expired invite);
|
||||||
|
* - a RESET link once they have (forgotten password). Their current password
|
||||||
|
* keeps working until the link is used; using it signs them out everywhere.
|
||||||
|
* The previous link stops working either way.
|
||||||
|
* Owners: anyone in the business but themselves. Managers: their own branch's cashiers.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, MANAGERS);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
const user = await User.findOne({ _id: params.id, businessId: auth.businessId });
|
||||||
|
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (auth.role === "BRANCH_MANAGER" && !(user.role === "DELIVERY" && user.branchId?.toString() === auth.branchId)) {
|
||||||
|
return NextResponse.json({ error: "Branch managers may only do this for delivery staff in their own branch" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (user._id.toString() === auth.sub) {
|
||||||
|
return NextResponse.json({ error: "Change your own password from Change password instead" }, { status: 409 });
|
||||||
|
}
|
||||||
|
if (!user.isActive) {
|
||||||
|
return NextResponse.json({ error: "Reactivate this account first" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const purpose = invitePending(user) ? "invite" : "reset";
|
||||||
|
const inviteUrl = issueInvite(user, req, purpose);
|
||||||
|
await user.save();
|
||||||
|
return NextResponse.json({ inviteUrl, purpose, expiresAt: user.inviteExpiresAt });
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
|
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { objectId } from "@/lib/server/validation";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const updateSchema = z
|
||||||
|
.object({
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
branchId: objectId.optional(), // owner only: move staff to another branch
|
||||||
|
})
|
||||||
|
.refine((v) => v.isActive !== undefined || v.branchId !== undefined, { message: "No fields to update" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/users/:id — deactivate/reactivate a staff account, or (owner
|
||||||
|
* only) move it to another branch. This is how an owner "removes" a user:
|
||||||
|
* the account and its sales history stay, but it can no longer sign in.
|
||||||
|
*
|
||||||
|
* SUPER_ADMIN any user in the business except themselves and the owner of record
|
||||||
|
* BRANCH_MANAGER only DELIVERY staff in their own branch, and only isActive
|
||||||
|
*/
|
||||||
|
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const gate = authorize(req, MANAGERS);
|
||||||
|
if (!gate.ok) return gate.response;
|
||||||
|
const auth = gate.auth;
|
||||||
|
|
||||||
|
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const json = await req.json().catch(() => null);
|
||||||
|
const parsed = updateSchema.safeParse(json);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { isActive, branchId } = parsed.data;
|
||||||
|
|
||||||
|
const target = await User.findOne({ _id: params.id, businessId: auth.businessId });
|
||||||
|
if (!target) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
|
||||||
|
if (target._id.toString() === auth.sub) {
|
||||||
|
return NextResponse.json({ error: "You cannot change your own account here" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.role === "BRANCH_MANAGER") {
|
||||||
|
const ownStaff = target.role === "DELIVERY" && target.branchId?.toString() === auth.branchId;
|
||||||
|
if (!ownStaff) {
|
||||||
|
return NextResponse.json({ error: "Branch managers may only manage delivery staff in their own branch" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (branchId !== undefined) {
|
||||||
|
return NextResponse.json({ error: "Only the owner can move staff between branches" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const business = await Business.findById(auth.businessId).select("ownerId").lean();
|
||||||
|
if (business && business.ownerId.toString() === target._id.toString()) {
|
||||||
|
return NextResponse.json({ error: "The business owner's account cannot be changed" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (branchId !== undefined) {
|
||||||
|
if (target.role === "SUPER_ADMIN") {
|
||||||
|
return NextResponse.json({ error: "Owner accounts are not tied to a branch" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
||||||
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
const fromBranch = target.branchId;
|
||||||
|
target.branchId = branch._id;
|
||||||
|
// A moved manager stops being the old branch's manager, and becomes the
|
||||||
|
// new branch's manager if it has none.
|
||||||
|
if (target.role === "BRANCH_MANAGER" && fromBranch?.toString() !== branch._id.toString()) {
|
||||||
|
await Branch.updateOne({ _id: fromBranch, businessId: auth.businessId, managerId: target._id }, { managerId: null });
|
||||||
|
await Branch.updateOne({ _id: branch._id, businessId: auth.businessId, managerId: null }, { managerId: target._id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isActive !== undefined) target.isActive = isActive;
|
||||||
|
await target.save();
|
||||||
|
|
||||||
|
// Revoke every device session so a deactivated (or moved) user cannot
|
||||||
|
// mint new access tokens; any current access token expires within 15m.
|
||||||
|
if (isActive === false || branchId !== undefined) {
|
||||||
|
await UserSession.deleteMany({ userId: target._id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = target.toObject() as unknown as Record<string, unknown>;
|
||||||
|
delete obj.passwordHash;
|
||||||
|
delete obj.inviteTokenHash;
|
||||||
|
return NextResponse.json({ user: obj });
|
||||||
|
}
|
||||||
+38
-29
@@ -3,43 +3,49 @@ import bcrypt from "bcryptjs";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { objectId } from "@/lib/server/validation";
|
||||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||||
import { userLimitError, loadTier } from "@/lib/server/subscription";
|
import { userLimitError, loadTier } from "@/lib/server/subscription";
|
||||||
|
import { invitePending, issueInvite, unusablePasswordHash } from "@/lib/server/invites";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, MANAGERS);
|
||||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
if (!gate.ok) return gate.response;
|
||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
const auth = gate.auth;
|
||||||
}
|
|
||||||
|
|
||||||
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
|
const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId"));
|
||||||
const filter: Record<string, unknown> = { businessId: auth!.businessId };
|
if (!scope.ok) return scope.response;
|
||||||
if (branchId) filter.branchId = branchId;
|
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||||
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
|
if (scope.branchId) filter.branchId = scope.branchId;
|
||||||
return NextResponse.json({ users });
|
const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean();
|
||||||
|
// invitePending: the person has not set their password yet.
|
||||||
|
return NextResponse.json({
|
||||||
|
users: users.map((u) => ({ ...u, invitePending: invitePending(u) })),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(120),
|
name: z.string().trim().min(1).max(120),
|
||||||
role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]),
|
role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]),
|
||||||
branchId: z.string().min(1).optional(),
|
branchId: objectId.optional(),
|
||||||
phoneNumber: z.string().min(9).max(15),
|
phoneNumber: z.string().min(9).max(15),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8),
|
// Omit the password to invite instead: the response carries a one-time
|
||||||
|
// link the person uses to set their own password.
|
||||||
|
password: z.string().min(8).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
const auth = getAuth(req);
|
const gate = authorize(req, MANAGERS);
|
||||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
if (!gate.ok) return gate.response;
|
||||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
const auth = gate.auth;
|
||||||
}
|
|
||||||
|
|
||||||
const json = await req.json().catch(() => null);
|
const json = await req.json().catch(() => null);
|
||||||
const parsed = createSchema.safeParse(json);
|
const parsed = createSchema.safeParse(json);
|
||||||
@@ -48,22 +54,22 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
|
|
||||||
if (auth!.role === "BRANCH_MANAGER") {
|
if (auth.role === "BRANCH_MANAGER") {
|
||||||
if (data.role !== "DELIVERY") {
|
if (data.role !== "DELIVERY") {
|
||||||
return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 });
|
return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 });
|
||||||
}
|
}
|
||||||
if (data.branchId && data.branchId !== auth!.branchId) {
|
if (data.branchId && data.branchId !== auth.branchId) {
|
||||||
return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 });
|
return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth!.branchId ?? undefined;
|
const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth.branchId ?? undefined;
|
||||||
if (data.role !== "SUPER_ADMIN" && !branchId) {
|
if (data.role !== "SUPER_ADMIN" && !branchId) {
|
||||||
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
|
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (branchId) {
|
if (branchId) {
|
||||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth!.businessId }).lean();
|
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
||||||
if (!branch) {
|
if (!branch) {
|
||||||
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -77,15 +83,15 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enforce the plan's user cap (counts owner + managers + staff).
|
// Enforce the plan's user cap (counts owner + managers + staff).
|
||||||
const tier = await loadTier(auth!.businessId);
|
const tier = await loadTier(auth.businessId);
|
||||||
const userErr = await userLimitError(auth!.businessId, tier);
|
const userErr = await userLimitError(auth.businessId, tier);
|
||||||
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
|
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
const passwordHash = data.password ? await bcrypt.hash(data.password, 12) : await unusablePasswordHash();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await User.create({
|
const user = new User({
|
||||||
businessId: auth!.businessId,
|
businessId: auth.businessId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
branchId: branchId ?? null,
|
branchId: branchId ?? null,
|
||||||
@@ -93,20 +99,23 @@ export async function POST(req: NextRequest) {
|
|||||||
email: data.email.toLowerCase(),
|
email: data.email.toLowerCase(),
|
||||||
passwordHash,
|
passwordHash,
|
||||||
});
|
});
|
||||||
|
const inviteUrl = data.password ? null : issueInvite(user, req);
|
||||||
|
await user.save();
|
||||||
|
|
||||||
if (data.role === "BRANCH_MANAGER" && branchId) {
|
if (data.role === "BRANCH_MANAGER" && branchId) {
|
||||||
await Branch.findOneAndUpdate(
|
await Branch.findOneAndUpdate(
|
||||||
{ _id: branchId, businessId: auth!.businessId },
|
{ _id: branchId, businessId: auth.businessId },
|
||||||
{ managerId: user._id }
|
{ managerId: user._id }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const obj: any = user.toObject();
|
const obj: any = user.toObject();
|
||||||
delete obj.passwordHash;
|
delete obj.passwordHash;
|
||||||
return NextResponse.json({ user: obj }, { status: 201 });
|
delete obj.inviteTokenHash;
|
||||||
|
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === 11000) {
|
if (err?.code === 11000) {
|
||||||
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
|
return NextResponse.json({ error: "Someone in your business already uses this phone number or email" }, { status: 409 });
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,3 +28,25 @@ body {
|
|||||||
.tap-target {
|
.tap-target {
|
||||||
min-height: 3.25rem;
|
min-height: 3.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Receipts: printing from the terminal prints only the receipt. */
|
||||||
|
@media print {
|
||||||
|
body * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.print-receipt,
|
||||||
|
.print-receipt * {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.print-receipt {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 72mm;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Invite {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
businessName: string | null;
|
||||||
|
purpose: "invite" | "reset";
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 text-depth-900 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25";
|
||||||
|
|
||||||
|
/** Where an invited staff member sets their own password. */
|
||||||
|
export default function InvitePage() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [invite, setInvite] = useState<Invite | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<Invite>("/api/invites/" + encodeURIComponent(token))
|
||||||
|
.then(setInvite)
|
||||||
|
.catch((err) => setLoadError(err instanceof ApiError ? err.message : "Could not load this invite."));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (password.length < 8) return setError("Use at least 8 characters.");
|
||||||
|
if (password !== confirm) return setError("The two passwords do not match.");
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/invites/" + encodeURIComponent(token), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
router.replace("/login?invited=1");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Could not save your password.");
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-5 py-10">
|
||||||
|
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-soft">
|
||||||
|
{loadError ? (
|
||||||
|
<p role="alert" className="text-sm text-alert-600">
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
) : !invite ? (
|
||||||
|
<div className="h-32 animate-pulse rounded-xl2 bg-sand-100" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-flow-600">{invite.businessName ?? "Vessel"}</p>
|
||||||
|
<h1 className="mt-1 font-display text-2xl font-bold text-depth-900">
|
||||||
|
{invite.purpose === "reset" ? `Reset your password, ${invite.name}` : `Welcome, ${invite.name}`}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-depth-600">
|
||||||
|
{invite.purpose === "reset" ? "Choose a new password. Every device signed in to your account will be signed out." : "Choose a password."}{" "}
|
||||||
|
You will sign in with <strong>{invite.email}</strong> and this password.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Confirm password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="tap-target w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Set password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ToastProvider } from "@/components/ui/Toast";
|
import { ToastProvider } from "@/components/ui/Toast";
|
||||||
|
import ServiceWorkerRegister from "@/components/ServiceWorkerRegister";
|
||||||
|
|
||||||
// NOTE: We deliberately do NOT use `next/font/google` here. That loader
|
// NOTE: We deliberately do NOT use `next/font/google` here. That loader
|
||||||
// downloads the font files from Google at *build time*, which fails in
|
// downloads the font files from Google at *build time*, which fails in
|
||||||
@@ -34,6 +35,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<ServiceWorkerRegister />
|
||||||
<ToastProvider>{children}</ToastProvider>
|
<ToastProvider>{children}</ToastProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+73
-4
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
|
import { getDeviceId, getDeviceLabel } from "@/lib/deviceId";
|
||||||
@@ -12,6 +12,18 @@ interface ActiveDevice {
|
|||||||
lastUsedAt: string;
|
lastUsedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BusinessChoice {
|
||||||
|
businessId: string;
|
||||||
|
businessName: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
SUPER_ADMIN: "Owner",
|
||||||
|
BRANCH_MANAGER: "Branch manager",
|
||||||
|
DELIVERY: "Cashier",
|
||||||
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -21,9 +33,21 @@ export default function LoginPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null);
|
const [deviceConflict, setDeviceConflict] = useState<ActiveDevice[] | null>(null);
|
||||||
|
// Set when this email and password open accounts in more than one business.
|
||||||
|
const [choices, setChoices] = useState<BusinessChoice[] | null>(null);
|
||||||
|
const [invited, setInvited] = useState(false);
|
||||||
|
const [showForgot, setShowForgot] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInvited(new URLSearchParams(window.location.search).get("invited") === "1");
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
await signIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(businessId?: string) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setDeviceConflict(null);
|
setDeviceConflict(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -36,11 +60,15 @@ export default function LoginPage() {
|
|||||||
password,
|
password,
|
||||||
deviceId: getDeviceId(),
|
deviceId: getDeviceId(),
|
||||||
deviceLabel: getDeviceLabel(),
|
deviceLabel: getDeviceLabel(),
|
||||||
|
businessId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
router.replace(result.redirectTo);
|
router.replace(result.redirectTo);
|
||||||
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError && err.status === 409) {
|
if (err instanceof ApiError && err.status === 300) {
|
||||||
|
setChoices((err.body?.chooseBusiness as BusinessChoice[]) ?? []);
|
||||||
|
} else if (err instanceof ApiError && err.status === 409) {
|
||||||
setDeviceConflict((err.body?.activeDevices as ActiveDevice[]) ?? []);
|
setDeviceConflict((err.body?.activeDevices as ActiveDevice[]) ?? []);
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} else if (err instanceof ApiError) {
|
} else if (err instanceof ApiError) {
|
||||||
@@ -70,7 +98,35 @@ export default function LoginPage() {
|
|||||||
Run every branch with confidence — sales, stock, M-Pesa and cash.
|
Run every branch with confidence — sales, stock, M-Pesa and cash.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{deviceConflict ? (
|
{choices && !deviceConflict ? (
|
||||||
|
<div className="mt-8">
|
||||||
|
<p className="text-sm font-medium text-depth-800">Which business do you want to sign in to?</p>
|
||||||
|
{error && choices.length === 0 && <p className="mt-2 text-sm text-alert-600">{error}</p>}
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{choices.map((c) => (
|
||||||
|
<li key={c.businessId}>
|
||||||
|
<button
|
||||||
|
onClick={() => signIn(c.businessId)}
|
||||||
|
disabled={loading}
|
||||||
|
className="tap-target flex w-full items-center justify-between rounded-xl2 border border-depth-200 bg-white px-4 text-left transition hover:border-flow-500 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-depth-900">{c.businessName}</span>
|
||||||
|
<span className="text-xs text-depth-500">{ROLE_LABELS[c.role] ?? c.role}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setChoices(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="mt-4 text-sm text-depth-500 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Use a different email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : deviceConflict ? (
|
||||||
<div className="mt-8 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-4">
|
<div className="mt-8 rounded-xl2 border border-alert-500/30 bg-alert-500/10 p-4">
|
||||||
<p className="text-sm font-medium text-alert-600">{error}</p>
|
<p className="text-sm font-medium text-alert-600">{error}</p>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 space-y-2">
|
||||||
@@ -171,9 +227,22 @@ export default function LoginPage() {
|
|||||||
/>
|
/>
|
||||||
Remember me
|
Remember me
|
||||||
</label>
|
</label>
|
||||||
<span className="font-medium text-flow-600">Forgot password?</span>
|
<button type="button" onClick={() => setShowForgot((v) => !v)} className="font-medium text-flow-600">
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showForgot && (
|
||||||
|
<p className="rounded-lg bg-sand-100 px-3 py-2 text-sm text-depth-700">
|
||||||
|
Ask your branch manager or business owner for a password reset link (Staff page, Reset password). If
|
||||||
|
you are the owner, contact Digital Chances support for one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{invited && !error && (
|
||||||
|
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600">
|
||||||
|
Your password is set. Sign in with your email and new password.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
// Lets cashiers add the terminal to their home screen and open it full-screen.
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "Vessel Branch Terminal",
|
||||||
|
short_name: "Vessel",
|
||||||
|
start_url: "/terminal",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#F5F2EF",
|
||||||
|
theme_color: "#0C0C0C",
|
||||||
|
};
|
||||||
|
}
|
||||||
+3
-1
@@ -12,7 +12,9 @@ export default function RootPage() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
|
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
|
||||||
if (result.user.role === "DELIVERY") {
|
if (result.user.isPlatformAdmin) {
|
||||||
|
router.replace("/platform");
|
||||||
|
} else if (result.user.role === "DELIVERY") {
|
||||||
router.replace("/terminal");
|
router.replace("/terminal");
|
||||||
} else {
|
} else {
|
||||||
router.replace("/admin/dashboard");
|
router.replace("/admin/dashboard");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { apiFetch, ApiError } from "@/lib/api";
|
|||||||
import { AuthUser } from "@/lib/types";
|
import { AuthUser } from "@/lib/types";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { SkeletonList } from "@/components/ui/Skeleton";
|
import { SkeletonList } from "@/components/ui/Skeleton";
|
||||||
|
import AccountLinks from "@/components/AccountLinks";
|
||||||
|
|
||||||
type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
|
type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
|
||||||
type SubStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
type SubStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
||||||
@@ -63,6 +64,7 @@ export default function PlatformPage() {
|
|||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [ownerLink, setOwnerLink] = useState<{ business: string; name: string; email: string; url: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -107,6 +109,23 @@ export default function PlatformPage() {
|
|||||||
setSavingId(null);
|
setSavingId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-time password link for a business's owner (an owner who forgot
|
||||||
|
// their password has nobody inside the business who can reset it).
|
||||||
|
async function ownerReset(b: BizRow) {
|
||||||
|
if (!window.confirm(`Create a password link for the owner of ${b.name}? Send it only to the owner, by a channel you know is theirs.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await apiFetch<{ inviteUrl: string; email: string; name: string }>(
|
||||||
|
"/api/platform/businesses/" + b.id + "/owner-reset",
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
setOwnerLink({ business: b.name, name: r.name, email: r.email, url: r.inviteUrl });
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not create the link", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-depth-950 pb-12">
|
<main className="min-h-screen bg-depth-950 pb-12">
|
||||||
@@ -130,6 +149,12 @@ export default function PlatformPage() {
|
|||||||
Every business on your platform, across all tenants. Change plans, extend billing periods,
|
Every business on your platform, across all tenants. Change plans, extend billing periods,
|
||||||
and suspend accounts here.
|
and suspend accounts here.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-3 flex items-center gap-4">
|
||||||
|
<a href="/admin/dashboard" className="text-sm text-sand-200/70 underline underline-offset-2 hover:text-white">
|
||||||
|
Your business dashboard
|
||||||
|
</a>
|
||||||
|
<AccountLinks />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -144,6 +169,28 @@ export default function PlatformPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ownerLink && (
|
||||||
|
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm">
|
||||||
|
<p className="font-medium text-white">
|
||||||
|
Password link for {ownerLink.name} ({ownerLink.email}), owner of {ownerLink.business}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-white/60">
|
||||||
|
It works once and expires within 24 hours (7 days if they never set a password). Whoever opens it can take
|
||||||
|
over the account: send it only to the owner, by a channel you know is theirs.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={ownerLink.url}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
aria-label="Owner password link"
|
||||||
|
className="mt-3 w-full rounded-lg border border-white/20 bg-depth-900 px-3 py-2 font-mono text-xs text-white"
|
||||||
|
/>
|
||||||
|
<button onClick={() => setOwnerLink(null)} className="mt-2 text-xs text-white/60 underline">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SkeletonList rows={4} />
|
<SkeletonList rows={4} />
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
@@ -158,6 +205,7 @@ export default function PlatformPage() {
|
|||||||
b={b}
|
b={b}
|
||||||
saving={savingId === b.id}
|
saving={savingId === b.id}
|
||||||
onPatch={patch}
|
onPatch={patch}
|
||||||
|
onOwnerReset={ownerReset}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,10 +228,12 @@ function BusinessCard({
|
|||||||
b,
|
b,
|
||||||
saving,
|
saving,
|
||||||
onPatch,
|
onPatch,
|
||||||
|
onOwnerReset,
|
||||||
}: {
|
}: {
|
||||||
b: BizRow;
|
b: BizRow;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
onPatch: (id: string, update: Record<string, unknown>, label: string) => void;
|
onPatch: (id: string, update: Record<string, unknown>, label: string) => void;
|
||||||
|
onOwnerReset: (b: BizRow) => void;
|
||||||
}) {
|
}) {
|
||||||
const overBranches = b.maxBranches !== null && b.branchCount > b.maxBranches;
|
const overBranches = b.maxBranches !== null && b.branchCount > b.maxBranches;
|
||||||
const overUsers = b.maxUsers !== null && b.userCount > b.maxUsers;
|
const overUsers = b.maxUsers !== null && b.userCount > b.maxUsers;
|
||||||
@@ -287,6 +337,15 @@ function BusinessCard({
|
|||||||
Reactivate
|
Reactivate
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{b.owner && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOwnerReset(b)}
|
||||||
|
className="tap-target rounded-xl2 border border-white/20 px-4 text-sm font-medium text-white/80"
|
||||||
|
>
|
||||||
|
Owner password link
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+448
-123
@@ -1,37 +1,67 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
import { isValidKenyanPhone } from "@/lib/phone";
|
import { isValidKenyanPhone } from "@/lib/phone";
|
||||||
import { AuthUser, PaymentMethod, Product } from "@/lib/types";
|
import { AuthUser, PaymentMethod, Product, StockLevel } from "@/lib/types";
|
||||||
import { generateClientUuid, queueCashSale, registerOfflineSync } from "@/lib/offlineQueue";
|
import {
|
||||||
|
dismissRejected,
|
||||||
|
flushQueue,
|
||||||
|
generateClientUuid,
|
||||||
|
queueCounts,
|
||||||
|
queueSale,
|
||||||
|
registerOfflineSync,
|
||||||
|
salesFor,
|
||||||
|
unsyncedQuantities,
|
||||||
|
type QueuedSale,
|
||||||
|
type QueueOwner,
|
||||||
|
} from "@/lib/offlineQueue";
|
||||||
|
import { loadSession, saveSession, type StockSnapshot } from "@/lib/sessionCache";
|
||||||
|
import { signOut } from "@/lib/signOut";
|
||||||
|
import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import ProductSizeSelector from "@/components/terminal/ProductSizeSelector";
|
import ProductGrid from "@/components/terminal/ProductGrid";
|
||||||
import QuantityCounter from "@/components/terminal/QuantityCounter";
|
import Receipt from "@/components/terminal/Receipt";
|
||||||
import PaymentMethodToggle from "@/components/terminal/PaymentMethodToggle";
|
import PaymentMethodToggle from "@/components/terminal/PaymentMethodToggle";
|
||||||
import PaymentStatusOverlay, { MpesaUiState } from "@/components/terminal/PaymentStatusOverlay";
|
import PaymentStatusOverlay, { MpesaUiState } from "@/components/terminal/PaymentStatusOverlay";
|
||||||
|
|
||||||
type ScreenState =
|
type ScreenState =
|
||||||
| { kind: "idle" }
|
| { kind: "idle" }
|
||||||
| { kind: "cash_saved" }
|
| { kind: "receipt"; sale: QueuedSale }
|
||||||
| { kind: "mpesa"; transactionId: string; ui: MpesaUiState; receipt?: string | null; reason?: string | null };
|
| { kind: "mpesa"; transactionId: string; ui: MpesaUiState; receipt?: string | null; reason?: string | null };
|
||||||
|
|
||||||
|
interface CartLine {
|
||||||
|
productId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TerminalPage() {
|
export default function TerminalPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { show } = useToast();
|
const { show } = useToast();
|
||||||
const [me, setMe] = useState<AuthUser | null>(null);
|
const [me, setMe] = useState<AuthUser | null>(null);
|
||||||
const [authChecked, setAuthChecked] = useState(false);
|
const [mpesaEnabled, setMpesaEnabled] = useState(false);
|
||||||
|
const [bootError, setBootError] = useState<string | null>(null);
|
||||||
const [online, setOnline] = useState(true);
|
const [online, setOnline] = useState(true);
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
// navigator.onLine only knows about the phone's own connection; this
|
||||||
|
// also goes false when the server could not be reached.
|
||||||
|
const [serverReachable, setServerReachable] = useState(true);
|
||||||
|
const [counts, setCounts] = useState({ pending: 0, rejected: 0 });
|
||||||
|
const [showQueue, setShowQueue] = useState(false);
|
||||||
|
const [queueRows, setQueueRows] = useState<QueuedSale[]>([]);
|
||||||
|
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [productsLoading, setProductsLoading] = useState(true);
|
const [productsLoading, setProductsLoading] = useState(true);
|
||||||
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
|
const [cart, setCart] = useState<CartLine[]>([]);
|
||||||
|
// Branch stock from the server, minus this phone's sales the server has
|
||||||
|
// not counted yet — so the till deducts stock the moment a sale is
|
||||||
|
// recorded, even offline (PRD 5.3).
|
||||||
|
const [stockSnapshot, setStockSnapshot] = useState<StockSnapshot | null>(null);
|
||||||
|
const [unsynced, setUnsynced] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
const [showCustomer, setShowCustomer] = useState(false);
|
||||||
const [customerName, setCustomerName] = useState("");
|
const [customerName, setCustomerName] = useState("");
|
||||||
const [customerPhone, setCustomerPhone] = useState("");
|
const [customerPhone, setCustomerPhone] = useState("");
|
||||||
const [quantity, setQuantity] = useState(1);
|
|
||||||
const [method, setMethod] = useState<PaymentMethod>("CASH");
|
const [method, setMethod] = useState<PaymentMethod>("CASH");
|
||||||
const [phoneError, setPhoneError] = useState<string | null>(null);
|
const [phoneError, setPhoneError] = useState<string | null>(null);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
@@ -40,49 +70,121 @@ export default function TerminalPage() {
|
|||||||
const [screen, setScreen] = useState<ScreenState>({ kind: "idle" });
|
const [screen, setScreen] = useState<ScreenState>({ kind: "idle" });
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const selectedProduct = products.find((p) => p._id === selectedProductId) ?? null;
|
const owner: QueueOwner | null = me ? { id: me.id, businessId: me.businessId, branchId: me.branchId } : null;
|
||||||
const amount = selectedProduct ? quantity * selectedProduct.unitPrice : 0;
|
const productById = new Map(products.map((p) => [p._id, p]));
|
||||||
|
const lines = cart
|
||||||
|
.map((c) => ({ ...c, product: productById.get(c.productId) }))
|
||||||
|
.filter((c): c is CartLine & { product: Product } => !!c.product);
|
||||||
|
const total = lines.reduce((sum, l) => sum + l.quantity * l.product.unitPrice, 0);
|
||||||
|
|
||||||
|
const refreshCounts = useCallback(async (userId: string) => {
|
||||||
|
setCounts(await queueCounts(userId));
|
||||||
|
const snapshotAt = loadSession()?.stock?.fetchedAt ?? null;
|
||||||
|
setUnsynced(unsyncedQuantities(await salesFor(userId), snapshotAt));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshStock = useCallback(
|
||||||
|
async (user: AuthUser) => {
|
||||||
|
const fetchedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ levels: StockLevel[] }>("/api/stock");
|
||||||
|
const snapshot: StockSnapshot = {
|
||||||
|
byProduct: Object.fromEntries(res.levels.map((l) => [l.productId, l.quantity])),
|
||||||
|
fetchedAt,
|
||||||
|
};
|
||||||
|
saveSession({ user, stock: snapshot });
|
||||||
|
setStockSnapshot(snapshot);
|
||||||
|
} catch {
|
||||||
|
// Offline: keep showing the last snapshot minus local sales.
|
||||||
|
const saved = loadSession();
|
||||||
|
if (saved?.user.id === user.id && saved.stock) setStockSnapshot(saved.stock);
|
||||||
|
}
|
||||||
|
await refreshCounts(user.id);
|
||||||
|
},
|
||||||
|
[refreshCounts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stockByProduct: Record<string, number> | null = stockSnapshot
|
||||||
|
? Object.fromEntries(
|
||||||
|
products.map((p) => [p._id, (stockSnapshot.byProduct[p._id] ?? 0) - (unsynced[p._id] ?? 0)])
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Boot: confirm the session online, or fall back to the last confirmed
|
||||||
|
// session on this device so a cashier can keep selling with no network.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const cached = loadSession();
|
||||||
|
let user: AuthUser;
|
||||||
try {
|
try {
|
||||||
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
|
const result = await apiFetch<{ user: AuthUser; features?: { mpesa: boolean } }>("/api/auth/me");
|
||||||
if (result.user.role === "SUPER_ADMIN") {
|
if (result.user.role === "SUPER_ADMIN") {
|
||||||
router.replace("/admin/dashboard");
|
router.replace("/admin/dashboard");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMe(result.user);
|
user = result.user;
|
||||||
} catch {
|
setMpesaEnabled(!!result.features?.mpesa);
|
||||||
router.replace("/login");
|
saveSession({ user, features: { mpesa: !!result.features?.mpesa } });
|
||||||
return;
|
} catch (err) {
|
||||||
|
// Signed out or blocked: go to login. Anything else (no network,
|
||||||
|
// server unreachable) falls back to the saved session.
|
||||||
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cached) {
|
||||||
|
setBootError("You're offline. Connect to the internet once to sign in on this phone.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
user = cached.user;
|
||||||
|
setServerReachable(false);
|
||||||
|
setMpesaEnabled(false); // M-Pesa needs a connection anyway
|
||||||
|
show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info");
|
||||||
}
|
}
|
||||||
setAuthChecked(true);
|
setMe(user);
|
||||||
|
await refreshStock(user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const productsResult = await apiFetch<{ products: Product[] }>("/api/products");
|
const productsResult = await apiFetch<{ products: Product[] }>("/api/products");
|
||||||
setProducts(productsResult.products);
|
setProducts(productsResult.products);
|
||||||
if (productsResult.products.length > 0) setSelectedProductId(productsResult.products[0]._id);
|
saveSession({ user, products: productsResult.products });
|
||||||
} catch {
|
} catch {
|
||||||
show("Could not load jerrycan sizes — check your connection", "error");
|
const saved = loadSession();
|
||||||
|
if (saved?.user.id === user.id && saved.products.length > 0) {
|
||||||
|
setProducts(saved.products);
|
||||||
|
} else {
|
||||||
|
show("Could not load products - check your connection", "error");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setProductsLoading(false);
|
setProductsLoading(false);
|
||||||
})();
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Connectivity + background sync for this cashier's queue.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!me) return;
|
||||||
setOnline(navigator.onLine);
|
setOnline(navigator.onLine);
|
||||||
const goOnline = () => {
|
const goOnline = () => {
|
||||||
setOnline(true);
|
setOnline(true);
|
||||||
show("Back online — syncing queued sales", "info");
|
show("Back online - syncing saved sales", "info");
|
||||||
};
|
};
|
||||||
const goOffline = () => {
|
const goOffline = () => {
|
||||||
setOnline(false);
|
setOnline(false);
|
||||||
show("You're offline — cash sales will keep saving and sync automatically", "info");
|
show("You're offline - cash sales keep saving and sync automatically", "info");
|
||||||
};
|
};
|
||||||
window.addEventListener("online", goOnline);
|
window.addEventListener("online", goOnline);
|
||||||
window.addEventListener("offline", goOffline);
|
window.addEventListener("offline", goOffline);
|
||||||
const unregister = registerOfflineSync((r) => {
|
const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
|
||||||
|
setCounts((c) => ({ ...c, pending: r.pending }));
|
||||||
|
setServerReachable(!r.offline);
|
||||||
if (r.synced > 0) {
|
if (r.synced > 0) {
|
||||||
setPendingCount((c) => Math.max(0, c - r.synced));
|
show(r.synced + " saved sale(s) synced", "success");
|
||||||
show(r.synced + " queued sale(s) synced", "success");
|
refreshStock(me);
|
||||||
|
}
|
||||||
|
if (r.rejected > 0) {
|
||||||
|
show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error");
|
||||||
|
refreshCounts(me.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,24 +195,46 @@ export default function TerminalPage() {
|
|||||||
if (pollRef.current) clearInterval(pollRef.current);
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [me?.id]);
|
||||||
|
|
||||||
|
function addProduct(productId: string) {
|
||||||
|
setFormError(null);
|
||||||
|
setCart((prev) => {
|
||||||
|
const existing = prev.find((l) => l.productId === productId);
|
||||||
|
if (existing) {
|
||||||
|
return prev.map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + 1) } : l));
|
||||||
|
}
|
||||||
|
if (prev.length >= MAX_LINES_PER_SALE) return prev;
|
||||||
|
return [...prev, { productId, quantity: 1 }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeQuantity(productId: string, delta: number) {
|
||||||
|
setCart((prev) =>
|
||||||
|
prev
|
||||||
|
.map((l) => (l.productId === productId ? { ...l, quantity: Math.min(500, l.quantity + delta) } : l))
|
||||||
|
.filter((l) => l.quantity > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
|
setCart([]);
|
||||||
setCustomerName("");
|
setCustomerName("");
|
||||||
setCustomerPhone("");
|
setCustomerPhone("");
|
||||||
setQuantity(1);
|
setShowCustomer(false);
|
||||||
setMethod("CASH");
|
setMethod("CASH");
|
||||||
setPhoneError(null);
|
setPhoneError(null);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setScreen({ kind: "idle" });
|
setScreen({ kind: "idle" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function validate(): boolean {
|
function validate(requirePhone: boolean): boolean {
|
||||||
if (!selectedProduct) {
|
if (lines.length === 0) {
|
||||||
setFormError("Choose a jerrycan size first.");
|
setFormError("Add at least one product.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!isValidKenyanPhone(customerPhone)) {
|
if ((requirePhone || customerPhone.trim()) && !isValidKenyanPhone(customerPhone)) {
|
||||||
|
setShowCustomer(true);
|
||||||
setPhoneError("Enter a valid Kenyan number: 07XXXXXXXX or +2547XXXXXXXX");
|
setPhoneError("Enter a valid Kenyan number: 07XXXXXXXX or +2547XXXXXXXX");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -120,51 +244,70 @@ export default function TerminalPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCashSale() {
|
async function handleCashSale() {
|
||||||
if (!validate() || !selectedProduct) return;
|
if (!me || !owner || !validate(false)) return;
|
||||||
|
if (!me.branchId) {
|
||||||
|
setFormError("Your account is not assigned to a branch.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Warn, never block: the jerrycans may be physically there even if the
|
||||||
|
// system was not told (no opening count, an unrecorded delivery), and a
|
||||||
|
// sale that really happened must be recorded. Negative stock then shows
|
||||||
|
// up on the manager's Stock page to be corrected with a count.
|
||||||
|
if (stockByProduct) {
|
||||||
|
const short = lines.filter((l) => l.quantity > (stockByProduct[l.productId] ?? 0));
|
||||||
|
if (short.length > 0) {
|
||||||
|
const detail = short
|
||||||
|
.map((l) => `${l.product.label}: ${Math.max(stockByProduct[l.productId] ?? 0, 0)} in stock, selling ${l.quantity}`)
|
||||||
|
.join("\n");
|
||||||
|
if (!window.confirm(`Not enough stock recorded at this branch:\n${detail}\n\nRecord the sale anyway?`)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
const sale = {
|
// The IndexedDB write is the only step that must finish before we show
|
||||||
|
// success: it is what keeps the sale if the phone dies before syncing.
|
||||||
|
const sale = await queueSale({
|
||||||
clientUuid: generateClientUuid(),
|
clientUuid: generateClientUuid(),
|
||||||
customerName: customerName || undefined,
|
userId: me.id,
|
||||||
customerPhone,
|
businessId: me.businessId ?? "",
|
||||||
productId: selectedProduct._id,
|
branchId: me.branchId,
|
||||||
quantity,
|
customerName: customerName.trim() || undefined,
|
||||||
|
customerPhone: customerPhone.trim() || undefined,
|
||||||
|
lines: lines.map((l) => ({
|
||||||
|
productId: l.productId,
|
||||||
|
label: l.product.label,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitPrice: l.product.unitPrice,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
clientSubmittedAt: new Date().toISOString(),
|
clientSubmittedAt: new Date().toISOString(),
|
||||||
};
|
});
|
||||||
|
|
||||||
// IndexedDB write is the only step that must complete before we can
|
|
||||||
// safely show success — it's what guarantees the sale survives even if
|
|
||||||
// the app crashes or the device loses power before the network sync
|
|
||||||
// below finishes. It's also fast (a few ms), so this doesn't add
|
|
||||||
// perceptible delay.
|
|
||||||
await queueCashSale(sale);
|
|
||||||
setPendingCount((c) => c + 1);
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
setScreen({ kind: "receipt", sale });
|
||||||
|
setCart([]);
|
||||||
|
await refreshCounts(me.id); // deducts this sale from the stock shown
|
||||||
|
|
||||||
// OPTIMISTIC UI: show the confirmation screen immediately rather than
|
// Sync in the background; a failure leaves the sale queued for the
|
||||||
// waiting on the network round trip. The worker handed over the
|
// next automatic retry.
|
||||||
// jerrycan and took the cash — from their perspective the sale is
|
|
||||||
// already done, and the app should agree instantly. The actual sync
|
|
||||||
// to the server happens in the background; if it fails here, the sale
|
|
||||||
// is NOT lost — it stays in the offline queue and the 'online'
|
|
||||||
// listener / interval in offlineQueue.ts retries it automatically.
|
|
||||||
setScreen({ kind: "cash_saved" });
|
|
||||||
|
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
apiFetch("/api/transactions/cash", {
|
flushQueue(owner).then(async (r) => {
|
||||||
method: "POST",
|
setCounts((c) => ({ ...c, pending: r.pending }));
|
||||||
body: JSON.stringify({ sales: [sale] }),
|
setServerReachable(!r.offline);
|
||||||
})
|
if (r.synced > 0) refreshStock(me);
|
||||||
.then(() => setPendingCount((c) => Math.max(0, c - 1)))
|
const rows = await salesFor(me.id);
|
||||||
.catch(() => {
|
const updated = rows.find((row) => row.clientUuid === sale.clientUuid);
|
||||||
// stays queued; offline sync interval will retry — no toast here,
|
if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s));
|
||||||
// since this is a routine background retry, not a user-facing error
|
if (r.rejected > 0) refreshCounts(me.id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startMpesaPush() {
|
async function startMpesaPush() {
|
||||||
if (!validate() || !selectedProduct) return;
|
if (!validate(true)) return;
|
||||||
|
if (lines.length !== 1) {
|
||||||
|
setFormError("M-Pesa payments support one product per payment. Use cash for mixed sales.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
setFormError("M-Pesa requires an active network connection.");
|
setFormError("M-Pesa requires an active network connection.");
|
||||||
return;
|
return;
|
||||||
@@ -177,8 +320,8 @@ export default function TerminalPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
customerName: customerName || undefined,
|
customerName: customerName || undefined,
|
||||||
customerPhone,
|
customerPhone,
|
||||||
productId: selectedProduct._id,
|
productId: lines[0].productId,
|
||||||
quantity,
|
quantity: lines[0].quantity,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setScreen({ kind: "mpesa", transactionId: data.transactionId, ui: "AWAITING_PIN" });
|
setScreen({ kind: "mpesa", transactionId: data.transactionId, ui: "AWAITING_PIN" });
|
||||||
@@ -196,7 +339,10 @@ export default function TerminalPage() {
|
|||||||
|
|
||||||
pollRef.current = setInterval(async () => {
|
pollRef.current = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch<any>("/api/mpesa/status/" + transactionId);
|
const data = await apiFetch<{
|
||||||
|
paymentStatus: string;
|
||||||
|
mpesaDetails?: { receiptNumber?: string | null; resultDesc?: string | null };
|
||||||
|
}>("/api/mpesa/status/" + transactionId);
|
||||||
if (data.paymentStatus === "SUCCESS") {
|
if (data.paymentStatus === "SUCCESS") {
|
||||||
clearInterval(pollRef.current!);
|
clearInterval(pollRef.current!);
|
||||||
setScreen({ kind: "mpesa", transactionId, ui: "SUCCESS", receipt: data.mpesaDetails?.receiptNumber });
|
setScreen({ kind: "mpesa", transactionId, ui: "SUCCESS", receipt: data.mpesaDetails?.receiptNumber });
|
||||||
@@ -210,7 +356,15 @@ export default function TerminalPage() {
|
|||||||
});
|
});
|
||||||
} else if (Date.now() - start > 60000) {
|
} else if (Date.now() - start > 60000) {
|
||||||
clearInterval(pollRef.current!);
|
clearInterval(pollRef.current!);
|
||||||
setScreen({ kind: "mpesa", transactionId, ui: "FAILED", reason: "No response from customer in time." });
|
// Not "failed": the customer may still approve it, and the callback
|
||||||
|
// would then record it as paid. Taking cash now would count it twice.
|
||||||
|
setScreen({
|
||||||
|
kind: "mpesa",
|
||||||
|
transactionId,
|
||||||
|
ui: "FAILED",
|
||||||
|
reason:
|
||||||
|
"No confirmation yet. Do NOT take cash for this sale until a manager has checked whether the M-Pesa payment arrived.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// transient network hiccup while polling; keep trying until timeout above
|
// transient network hiccup while polling; keep trying until timeout above
|
||||||
@@ -218,7 +372,48 @@ export default function TerminalPage() {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authChecked || !me) {
|
async function openQueue() {
|
||||||
|
if (!me) return;
|
||||||
|
setQueueRows((await salesFor(me.id)).filter((r) => r.state !== "synced"));
|
||||||
|
setShowQueue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDismiss(clientUuid: string) {
|
||||||
|
if (!me) return;
|
||||||
|
if (!window.confirm("Remove this sale from the phone? Make sure your manager has a record of it first.")) return;
|
||||||
|
await dismissRejected(clientUuid);
|
||||||
|
setQueueRows((rows) => rows.filter((r) => r.clientUuid !== clientUuid));
|
||||||
|
await refreshCounts(me.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
if (counts.pending > 0) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`${counts.pending} sale(s) have not synced yet. They stay safely on this phone and sync the next time you sign in here. Sign out anyway?`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
await signOut();
|
||||||
|
router.replace("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bootError) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-depth-900 px-6 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-sand-200">{bootError}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="tap-target mt-6 rounded-xl2 bg-flow-500 px-6 font-display font-semibold text-depth-950"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!me) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-sand-50 pb-10">
|
<main className="min-h-screen bg-sand-50 pb-10">
|
||||||
<header className="bg-depth-900 px-5 pb-6 pt-8">
|
<header className="bg-depth-900 px-5 pb-6 pt-8">
|
||||||
@@ -232,95 +427,189 @@ export default function TerminalPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity]));
|
||||||
|
const connected = online && serverReachable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-sand-50 pb-10">
|
<main className="min-h-screen bg-sand-50 pb-10">
|
||||||
<header className="bg-depth-900 px-5 pb-6 pt-8 text-white">
|
<header className="no-print bg-depth-900 px-5 pb-6 pt-8 text-white">
|
||||||
<div className="flex items-center justify-between">
|
<div className="mx-auto flex max-w-md items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-flow-400">{me.branchName || "Branch"}</p>
|
<p className="text-xs uppercase tracking-wide text-flow-400">{me.branchName || "Branch"}</p>
|
||||||
<h1 className="font-display text-lg font-bold">{me.name}</h1>
|
<h1 className="font-display text-lg font-bold">{me.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + (online ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")}>
|
<span
|
||||||
{online ? "● Online" : "● Offline"}
|
className={
|
||||||
|
"rounded-full px-2.5 py-1 text-xs font-medium " +
|
||||||
|
(connected ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{connected ? "● Online" : "● Offline"}
|
||||||
</span>
|
</span>
|
||||||
{pendingCount > 0 && <span className="text-xs text-cash-400">{pendingCount} cash sale(s) syncing…</span>}
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => router.push("/account/password")} className="text-xs text-sand-200/60 underline underline-offset-2">
|
||||||
|
Password
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mx-auto -mt-3 max-w-md space-y-4 px-5">
|
<div className="mx-auto -mt-3 max-w-md space-y-4 px-5">
|
||||||
{screen.kind === "cash_saved" ? (
|
{(counts.pending > 0 || counts.rejected > 0) && (
|
||||||
<div className="animate-fade-up rounded-xl2 border border-cash-500/30 bg-white p-6 text-center shadow-soft">
|
<button
|
||||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-cash-500/15 text-2xl">💵</div>
|
onClick={openQueue}
|
||||||
<h2 className="font-display text-lg font-bold text-depth-900">Cash Sale Recorded</h2>
|
className={
|
||||||
<p className="mt-1 text-sm text-depth-600">
|
"no-print w-full rounded-xl2 px-4 py-3 text-left text-sm font-medium shadow-soft " +
|
||||||
KES {amount.toLocaleString()} · {quantity} × {selectedProduct?.sizeLiters}L jerrycans
|
(counts.rejected > 0 ? "bg-alert-500 text-white" : "bg-cash-400 text-depth-900")
|
||||||
</p>
|
}
|
||||||
<button onClick={resetForm} className="tap-target mt-6 w-full rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 active:scale-[0.98]">
|
>
|
||||||
Start next sale
|
{counts.rejected > 0
|
||||||
</button>
|
? `${counts.rejected} sale(s) need attention - tap to see why`
|
||||||
|
: `${counts.pending} sale(s) saved on this phone, waiting to sync`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen.kind === "receipt" ? (
|
||||||
|
<div className="animate-fade-up space-y-4">
|
||||||
|
<Receipt
|
||||||
|
sale={screen.sale}
|
||||||
|
businessName={me.businessName}
|
||||||
|
branchName={me.branchName}
|
||||||
|
cashierName={me.name}
|
||||||
|
synced={screen.sale.state === "synced"}
|
||||||
|
/>
|
||||||
|
<div className="no-print grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="tap-target rounded-xl2 border border-depth-200 bg-white font-display font-semibold text-depth-800"
|
||||||
|
>
|
||||||
|
Print receipt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetForm}
|
||||||
|
className="tap-target rounded-xl2 bg-flow-500 font-display font-semibold text-depth-950 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Next sale
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-name">
|
|
||||||
Customer name (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="cust-name"
|
|
||||||
value={customerName}
|
|
||||||
onChange={(e) => setCustomerName(e.target.value)}
|
|
||||||
placeholder="e.g. Jane Wambui"
|
|
||||||
className="tap-target w-full rounded-xl border border-depth-200 px-3 mb-3"
|
|
||||||
/>
|
|
||||||
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-phone">
|
|
||||||
Customer phone
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="cust-phone"
|
|
||||||
inputMode="tel"
|
|
||||||
value={customerPhone}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomerPhone(e.target.value);
|
|
||||||
if (phoneError) setPhoneError(null);
|
|
||||||
}}
|
|
||||||
placeholder="07XXXXXXXX"
|
|
||||||
className={"tap-target w-full rounded-xl border px-3 " + (phoneError ? "border-alert-500" : "border-depth-200")}
|
|
||||||
/>
|
|
||||||
{phoneError && <p className="mt-1.5 text-sm text-alert-500">{phoneError}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{productsLoading ? (
|
{productsLoading ? (
|
||||||
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
|
<div className="h-24 animate-pulse rounded-xl2 bg-white shadow-soft" />
|
||||||
) : (
|
) : (
|
||||||
<ProductSizeSelector products={products} selectedId={selectedProductId} onSelect={setSelectedProductId} />
|
<ProductGrid products={products} quantities={quantities} stock={stockByProduct} onAdd={addProduct} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedProduct && (
|
{lines.length > 0 && (
|
||||||
<QuantityCounter quantity={quantity} onChange={setQuantity} unitPrice={selectedProduct.unitPrice} sizeLabel={selectedProduct.sizeLiters + "L"} />
|
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
|
||||||
|
<p className="mb-2 text-sm font-medium text-depth-600">This sale</p>
|
||||||
|
<ul className="divide-y divide-depth-100">
|
||||||
|
{lines.map((l) => (
|
||||||
|
<li key={l.productId} className="flex items-center justify-between gap-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-display text-sm font-bold text-depth-900">{l.product.label}</p>
|
||||||
|
<p className="font-mono text-xs text-depth-500">
|
||||||
|
KES {(l.quantity * l.product.unitPrice).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeQuantity(l.productId, -1)}
|
||||||
|
aria-label={`Remove one ${l.product.label}`}
|
||||||
|
className="flex h-11 w-11 items-center justify-center rounded-xl bg-sand-100 text-2xl font-bold text-depth-800 active:scale-95"
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<span className="w-8 text-center font-display text-lg font-bold">{l.quantity}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeQuantity(l.productId, 1)}
|
||||||
|
aria-label={`Add one ${l.product.label}`}
|
||||||
|
className="flex h-11 w-11 items-center justify-center rounded-xl bg-flow-500 text-2xl font-bold text-depth-950 active:scale-95"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-2 flex justify-between border-t border-depth-100 pt-2 font-display font-bold">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>KES {total.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCart([])}
|
||||||
|
className="mt-2 text-sm font-medium text-alert-600 underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Clear sale
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
|
||||||
|
{!showCustomer ? (
|
||||||
|
<button onClick={() => setShowCustomer(true)} className="text-sm font-medium text-flow-600 underline underline-offset-2">
|
||||||
|
+ Add customer details {method === "MPESA" ? "(phone required)" : "(optional)"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-name">
|
||||||
|
Customer name (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cust-name"
|
||||||
|
value={customerName}
|
||||||
|
onChange={(e) => setCustomerName(e.target.value)}
|
||||||
|
placeholder="e.g. Jane Wambui"
|
||||||
|
className="tap-target mb-3 w-full rounded-xl border border-depth-200 px-3"
|
||||||
|
/>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-depth-600" htmlFor="cust-phone">
|
||||||
|
Customer phone {method === "MPESA" ? "" : "(optional)"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="cust-phone"
|
||||||
|
inputMode="tel"
|
||||||
|
value={customerPhone}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomerPhone(e.target.value);
|
||||||
|
if (phoneError) setPhoneError(null);
|
||||||
|
}}
|
||||||
|
placeholder="07XXXXXXXX"
|
||||||
|
className={"tap-target w-full rounded-xl border px-3 " + (phoneError ? "border-alert-500" : "border-depth-200")}
|
||||||
|
/>
|
||||||
|
{phoneError && <p className="mt-1.5 text-sm text-alert-500">{phoneError}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{formError && <p className="text-sm text-alert-500">{formError}</p>}
|
{formError && <p className="text-sm text-alert-500">{formError}</p>}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PaymentMethodToggle value={method} onChange={setMethod} />
|
{mpesaEnabled && <PaymentMethodToggle value={method} onChange={setMethod} />}
|
||||||
|
|
||||||
{method === "CASH" ? (
|
{method === "CASH" || !mpesaEnabled ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleCashSale}
|
onClick={handleCashSale}
|
||||||
disabled={submitting || !selectedProduct}
|
disabled={submitting || lines.length === 0}
|
||||||
className="tap-target w-full rounded-xl2 bg-cash-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
|
className="tap-target w-full rounded-xl2 bg-cash-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{submitting ? "Saving…" : "Record Cash Sale · KES " + amount.toLocaleString()}
|
{submitting ? "Saving…" : "Record Cash Sale · KES " + total.toLocaleString()}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={startMpesaPush}
|
onClick={startMpesaPush}
|
||||||
disabled={submitting || !selectedProduct}
|
disabled={submitting || lines.length === 0}
|
||||||
className="tap-target w-full rounded-xl2 bg-confirm-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
|
className="tap-target w-full rounded-xl2 bg-confirm-500 font-display text-lg font-bold text-white shadow-soft transition active:scale-[0.98] disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{submitting ? "Sending prompt…" : "Initiate Payment · KES " + amount.toLocaleString()}
|
{submitting ? "Sending prompt…" : "Initiate Payment · KES " + total.toLocaleString()}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -328,10 +617,46 @@ export default function TerminalPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showQueue && (
|
||||||
|
<div role="dialog" aria-modal="true" className="no-print fixed inset-0 z-40 flex items-end justify-center bg-depth-950/60 sm:items-center">
|
||||||
|
<div className="max-h-[80vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-5 sm:rounded-3xl">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="font-display text-lg font-bold">Sales on this phone</h2>
|
||||||
|
<button onClick={() => setShowQueue(false)} className="text-sm text-depth-500 underline">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{queueRows.length === 0 && <p className="text-sm text-depth-500">Everything is synced.</p>}
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{queueRows.map((r) => (
|
||||||
|
<li key={r.clientUuid} className="rounded-xl border border-depth-200 p-3 text-sm">
|
||||||
|
<div className="flex justify-between font-medium">
|
||||||
|
<span>{new Date(r.clientSubmittedAt).toLocaleString("en-KE")}</span>
|
||||||
|
<span>KES {r.total.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-depth-500">{r.lines.map((l) => `${l.quantity} x ${l.label}`).join(", ")}</p>
|
||||||
|
{r.state === "pending" ? (
|
||||||
|
<p className="mt-1 text-xs text-cash-600">Waiting to sync</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 text-xs text-alert-600">Not accepted: {r.error}</p>
|
||||||
|
<p className="text-xs text-depth-500">Tell your manager, then remove it from this phone.</p>
|
||||||
|
<button onClick={() => handleDismiss(r.clientUuid)} className="mt-1 text-xs font-medium text-alert-600 underline">
|
||||||
|
Remove from phone
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{screen.kind === "mpesa" && (
|
{screen.kind === "mpesa" && (
|
||||||
<PaymentStatusOverlay
|
<PaymentStatusOverlay
|
||||||
state={screen.ui}
|
state={screen.ui}
|
||||||
amount={amount}
|
amount={total}
|
||||||
receiptNumber={screen.receipt}
|
receiptNumber={screen.receipt}
|
||||||
failureReason={screen.reason}
|
failureReason={screen.reason}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { signOut } from "@/lib/signOut";
|
||||||
|
|
||||||
|
/** Change password + Sign out, for the dark page headers. */
|
||||||
|
export default function AccountLinks() {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 whitespace-nowrap text-sm">
|
||||||
|
<Link href="/account/password" className="text-sand-200/70 underline underline-offset-2 hover:text-white">
|
||||||
|
Change password
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await signOut();
|
||||||
|
router.replace("/login");
|
||||||
|
}}
|
||||||
|
className="text-sand-200/70 underline underline-offset-2 hover:text-white"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers public/sw.js so the terminal can open offline. Production only:
|
||||||
|
* in `next dev` the build assets are not content-hashed, and caching them
|
||||||
|
* would serve stale code while developing.
|
||||||
|
*/
|
||||||
|
export default function ServiceWorkerRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== "production" || !("serviceWorker" in navigator)) return;
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {
|
||||||
|
// Unsupported or blocked: the app still works online.
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export default function ActivityFeedMatrix({ transactions, branchNames, connecte
|
|||||||
<li key={t._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
<li key={t._id} className="flex items-center justify-between gap-3 px-4 py-3 animate-fade-up">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="truncate text-sm font-medium text-depth-900">{t.customerName || t.customerPhone}</p>
|
<p className="truncate text-sm font-medium text-depth-900">{t.customerName || t.customerPhone || "Walk-in customer"}</p>
|
||||||
{scope === "global" && (
|
{scope === "global" && (
|
||||||
<span className="whitespace-nowrap rounded-full bg-depth-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-depth-600">
|
<span className="whitespace-nowrap rounded-full bg-depth-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-depth-600">
|
||||||
{branchLabel}
|
{branchLabel}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { StockLevel } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
levels: StockLevel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current stock per branch at a glance, with anything low or out listed first. */
|
||||||
|
export default function StockSummaryCard({ levels }: Props) {
|
||||||
|
const totalsByBranch = new Map<string, { name: string; units: number; alerts: number }>();
|
||||||
|
for (const l of levels) {
|
||||||
|
const entry = totalsByBranch.get(l.branchId) ?? { name: l.branchName, units: 0, alerts: 0 };
|
||||||
|
entry.units += Math.max(l.quantity, 0);
|
||||||
|
if (l.status !== "OK") entry.alerts += 1;
|
||||||
|
totalsByBranch.set(l.branchId, entry);
|
||||||
|
}
|
||||||
|
const alerts = levels.filter((l) => l.status !== "OK").sort((a, b) => a.quantity - b.quantity);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl2 bg-white p-4 shadow-soft">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="font-display font-semibold text-depth-900">Stock on hand</h2>
|
||||||
|
<Link href="/admin/stock" className="text-sm font-medium text-flow-600 underline underline-offset-2">
|
||||||
|
Manage stock
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{levels.length === 0 ? (
|
||||||
|
<p className="text-sm text-depth-500">No stock recorded yet.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{Array.from(totalsByBranch.entries()).map(([id, b]) => (
|
||||||
|
<div key={id} className="rounded-xl bg-sand-50 px-3 py-2">
|
||||||
|
<p className="truncate text-xs text-depth-500">{b.name}</p>
|
||||||
|
<p className="font-display text-lg font-bold text-depth-900">{b.units.toLocaleString()} units</p>
|
||||||
|
{b.alerts > 0 && <p className="text-xs font-medium text-cash-700">{b.alerts} low/out</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-1 text-sm">
|
||||||
|
{alerts.slice(0, 6).map((l) => (
|
||||||
|
<li key={l.branchId + l.productId} className="flex justify-between gap-3">
|
||||||
|
<span className="truncate text-depth-700">
|
||||||
|
{l.productLabel} · {l.branchName}
|
||||||
|
</span>
|
||||||
|
<span className={"shrink-0 font-mono " + (l.status === "OUT" ? "text-alert-600" : "text-cash-700")}>
|
||||||
|
{l.quantity}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Product } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
products: Product[];
|
||||||
|
quantities: Record<string, number>;
|
||||||
|
/** Stock left at this branch after this phone's own sales; null if unknown. */
|
||||||
|
stock: Record<string, number> | null;
|
||||||
|
onAdd: (productId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One tap adds one unit of a product to the current sale. */
|
||||||
|
export default function ProductGrid({ products, quantities, stock, onAdd }: Props) {
|
||||||
|
if (products.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
|
||||||
|
No products configured yet. Ask the owner to add them.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
|
||||||
|
<span className="mb-2 block text-sm font-medium text-depth-600">Tap a product to add it</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{products.map((p) => {
|
||||||
|
const inCart = quantities[p._id] ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p._id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAdd(p._id)}
|
||||||
|
aria-label={`Add ${p.label}`}
|
||||||
|
className={
|
||||||
|
"tap-target relative flex flex-col items-start justify-center rounded-xl2 border-2 py-2 pl-3 text-left transition active:scale-[0.97] " +
|
||||||
|
(inCart > 0 ? "border-flow-500 bg-flow-500/10 pr-9" : "border-depth-200 bg-white pr-3")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="font-display text-sm font-bold leading-tight text-depth-900">{p.label}</span>
|
||||||
|
<span className="font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
|
||||||
|
{stock && (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"text-xs font-medium " +
|
||||||
|
((stock[p._id] ?? 0) - inCart <= 0 ? "text-alert-600" : "text-depth-500")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Math.max((stock[p._id] ?? 0) - inCart, 0)} in stock
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{inCart > 0 && (
|
||||||
|
<span className="absolute right-2 top-2 flex h-6 min-w-6 items-center justify-center rounded-full bg-flow-500 px-1.5 font-mono text-xs font-bold text-white">
|
||||||
|
{inCart}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Product } from "@/lib/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
products: Product[];
|
|
||||||
selectedId: string | null;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProductSizeSelector({ products, selectedId, onSelect }: Props) {
|
|
||||||
if (products.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl2 border border-dashed border-depth-200 bg-white p-4 text-center text-sm text-depth-500">
|
|
||||||
No jerrycan sizes configured yet.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const maxSize = Math.max(...products.map((p) => p.sizeLiters));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span className="mb-1.5 block text-sm font-medium text-depth-600">Jerrycan size</span>
|
|
||||||
<div role="radiogroup" aria-label="Jerrycan size" className="flex flex-wrap gap-2">
|
|
||||||
{products.map((p) => {
|
|
||||||
const isSelected = p._id === selectedId;
|
|
||||||
const scale = 0.7 + 0.3 * (p.sizeLiters / maxSize);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={p._id}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={isSelected}
|
|
||||||
onClick={() => onSelect(p._id)}
|
|
||||||
className={
|
|
||||||
"tap-target flex items-center gap-2 rounded-xl2 border-2 px-4 transition " +
|
|
||||||
(isSelected ? "border-flow-500 bg-flow-500/10 text-depth-900" : "border-depth-200 bg-white text-depth-600")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg width={18 * scale} height={22 * scale} viewBox="0 0 24 28" className={isSelected ? "text-flow-500" : "text-depth-300"}>
|
|
||||||
<rect x="4" y="6" width="16" height="20" rx="2" fill="currentColor" />
|
|
||||||
<rect x="9" y="2" width="6" height="5" rx="1" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-left leading-tight">
|
|
||||||
<span className="block font-display text-sm font-bold">{p.sizeLiters}{p.unit ?? "L"}</span>
|
|
||||||
<span className="block font-mono text-xs text-depth-500">KES {p.unitPrice.toLocaleString()}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
quantity: number;
|
|
||||||
onChange: (q: number) => void;
|
|
||||||
unitPrice: number;
|
|
||||||
sizeLabel: string;
|
|
||||||
max?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function QuantityCounter({ quantity, onChange, unitPrice, sizeLabel, max = 200 }: Props) {
|
|
||||||
const dec = () => onChange(Math.max(1, quantity - 1));
|
|
||||||
const inc = () => onChange(Math.min(max, quantity + 1));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-depth-600">{sizeLabel} jerrycans</span>
|
|
||||||
<span className="font-mono text-sm text-depth-600">KES {unitPrice.toLocaleString()} each</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={dec}
|
|
||||||
aria-label="Decrease quantity"
|
|
||||||
className="tap-target flex w-16 items-center justify-center rounded-xl2 bg-sand-100 text-3xl font-bold text-depth-800 active:scale-95"
|
|
||||||
>
|
|
||||||
–
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-display text-4xl font-extrabold text-depth-900">{quantity}</div>
|
|
||||||
<div className="font-mono text-sm text-depth-600">KES {(quantity * unitPrice).toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={inc}
|
|
||||||
aria-label="Increase quantity"
|
|
||||||
className="tap-target flex w-16 items-center justify-center rounded-xl2 bg-flow-500 text-3xl font-bold text-depth-950 active:scale-95"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { QueuedSale } from "@/lib/offlineQueue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sale: QueuedSale;
|
||||||
|
businessName?: string | null;
|
||||||
|
branchName?: string | null;
|
||||||
|
cashierName: string;
|
||||||
|
synced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A printable sale receipt. Printing hides everything but `.print-receipt` (see globals.css). */
|
||||||
|
export default function Receipt({ sale, businessName, branchName, cashierName, synced }: Props) {
|
||||||
|
const when = new Date(sale.clientSubmittedAt);
|
||||||
|
const reference = sale.clientUuid.slice(0, 8).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print-receipt rounded-xl2 border border-depth-200/60 bg-white p-5 font-mono text-sm text-depth-900 shadow-soft">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-display text-base font-bold">{businessName ?? "Receipt"}</p>
|
||||||
|
{branchName && <p className="text-xs text-depth-500">{branchName}</p>}
|
||||||
|
<p className="mt-1 text-xs text-depth-500">
|
||||||
|
{when.toLocaleDateString("en-KE")} {when.toLocaleTimeString("en-KE", { hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-depth-500">Ref {reference}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="mt-4 w-full">
|
||||||
|
<tbody>
|
||||||
|
{sale.lines.map((l) => (
|
||||||
|
<tr key={l.productId} className="align-top">
|
||||||
|
<td className="py-0.5 pr-2">
|
||||||
|
{l.quantity} x {l.label}
|
||||||
|
<span className="block text-xs text-depth-500">@ KES {l.unitPrice.toLocaleString()}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-0.5 text-right">{(l.quantity * l.unitPrice).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="mt-3 flex justify-between border-t border-dashed border-depth-300 pt-2 font-bold">
|
||||||
|
<span>TOTAL (KES)</span>
|
||||||
|
<span>{sale.total.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs">Paid: Cash</p>
|
||||||
|
{sale.customerName && <p className="text-xs">Customer: {sale.customerName}</p>}
|
||||||
|
<p className="text-xs">Served by: {cashierName}</p>
|
||||||
|
|
||||||
|
<p className="no-print mt-3 text-center text-xs text-depth-500">
|
||||||
|
{synced ? "Synced to head office" : "Saved on this phone - will sync automatically"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+219
-33
@@ -1,7 +1,49 @@
|
|||||||
import { openDB, DBSchema, IDBPDatabase } from "idb";
|
import { openDB, DBSchema, IDBPDatabase } from "idb";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import { MAX_SALES_PER_REQUEST, type SaleResult } from "@/lib/syncProtocol";
|
||||||
|
|
||||||
export interface QueuedCashSale {
|
/**
|
||||||
|
* The terminal's durable sales queue (IndexedDB).
|
||||||
|
*
|
||||||
|
* Every cash sale is written here BEFORE the cashier sees "recorded", and
|
||||||
|
* stays here until the server has confirmed it. Each row carries the
|
||||||
|
* cashier and branch that recorded it, and a device only ever sends the
|
||||||
|
* signed-in cashier's rows — so on a shared phone, sales can never be
|
||||||
|
* credited to whoever signs in next.
|
||||||
|
*
|
||||||
|
* Row lifecycle:
|
||||||
|
* pending -> sent on the next sync (immediately when online)
|
||||||
|
* synced -> server answered saved/duplicate; kept 7 days for the receipt history
|
||||||
|
* rejected -> server can never accept it (reason stored); shown to the cashier,
|
||||||
|
* never retried, so it can't block the queue
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SaleLine {
|
||||||
|
productId: string;
|
||||||
|
label: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number; // the price the cashier saw and charged
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SaleState = "pending" | "synced" | "rejected";
|
||||||
|
|
||||||
|
export interface QueuedSale {
|
||||||
|
clientUuid: string;
|
||||||
|
userId: string;
|
||||||
|
businessId: string;
|
||||||
|
branchId: string;
|
||||||
|
customerName?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
lines: SaleLine[];
|
||||||
|
total: number;
|
||||||
|
clientSubmittedAt: string;
|
||||||
|
state: SaleState;
|
||||||
|
error?: string;
|
||||||
|
syncedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows written by the pre-v2 terminal (no owner, one product). */
|
||||||
|
interface LegacyCashSale {
|
||||||
clientUuid: string;
|
clientUuid: string;
|
||||||
customerName?: string;
|
customerName?: string;
|
||||||
customerPhone: string;
|
customerPhone: string;
|
||||||
@@ -12,19 +54,30 @@ export interface QueuedCashSale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface QueueDB extends DBSchema {
|
interface QueueDB extends DBSchema {
|
||||||
cash_sales: { key: string; value: QueuedCashSale };
|
sales: { key: string; value: QueuedSale; indexes: { byUser: string } };
|
||||||
|
cash_sales: { key: string; value: LegacyCashSale };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueOwner {
|
||||||
|
id: string;
|
||||||
|
businessId?: string;
|
||||||
|
branchId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DB_NAME = "aquaflow-offline";
|
const DB_NAME = "aquaflow-offline";
|
||||||
const STORE = "cash_sales";
|
const KEEP_SYNCED_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
let dbPromise: Promise<IDBPDatabase<QueueDB>> | null = null;
|
let dbPromise: Promise<IDBPDatabase<QueueDB>> | null = null;
|
||||||
|
|
||||||
function getDb() {
|
function getDb() {
|
||||||
if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser");
|
if (typeof window === "undefined") throw new Error("offlineQueue can only run in the browser");
|
||||||
if (!dbPromise) {
|
if (!dbPromise) {
|
||||||
dbPromise = openDB<QueueDB>(DB_NAME, 1, {
|
dbPromise = openDB<QueueDB>(DB_NAME, 2, {
|
||||||
upgrade(db) {
|
upgrade(db, oldVersion) {
|
||||||
db.createObjectStore(STORE, { keyPath: "clientUuid" });
|
if (oldVersion < 1) db.createObjectStore("cash_sales", { keyPath: "clientUuid" });
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
const sales = db.createObjectStore("sales", { keyPath: "clientUuid" });
|
||||||
|
sales.createIndex("byUser", "userId");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -35,52 +88,185 @@ export function generateClientUuid(): string {
|
|||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queueCashSale(sale: Omit<QueuedCashSale, "synced">): Promise<void> {
|
export async function queueSale(sale: Omit<QueuedSale, "state">): Promise<QueuedSale> {
|
||||||
|
const row: QueuedSale = { ...sale, state: "pending" };
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await db.put(STORE, { ...sale, synced: false });
|
await db.put("sales", row);
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPendingSales(): Promise<QueuedCashSale[]> {
|
export async function salesFor(userId: string): Promise<QueuedSale[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const all = await db.getAll(STORE);
|
return db.getAllFromIndex("sales", "byUser", userId);
|
||||||
return all.filter((s) => !s.synced);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markSynced(clientUuids: string[]) {
|
export async function queueCounts(userId: string): Promise<{ pending: number; rejected: number }> {
|
||||||
|
const rows = await salesFor(userId);
|
||||||
|
return {
|
||||||
|
pending: rows.filter((r) => r.state === "pending").length,
|
||||||
|
rejected: rows.filter((r) => r.state === "rejected").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Units this cashier has sold that the given server stock snapshot does
|
||||||
|
* not include yet: sales still pending, plus sales that synced after the
|
||||||
|
* snapshot was taken. Subtracting these from the snapshot gives the stock
|
||||||
|
* the till should show right now, online or offline.
|
||||||
|
*/
|
||||||
|
export function unsyncedQuantities(rows: QueuedSale[], snapshotAt: string | null): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
const notInSnapshot =
|
||||||
|
row.state === "pending" || (row.state === "synced" && (!snapshotAt || (row.syncedAt ?? "") > snapshotAt));
|
||||||
|
if (!notInSnapshot) continue;
|
||||||
|
for (const l of row.lines) out[l.productId] = (out[l.productId] ?? 0) + l.quantity;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */
|
||||||
|
export async function dismissRejected(clientUuid: string): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const tx = db.transaction(STORE, "readwrite");
|
const row = await db.get("sales", clientUuid);
|
||||||
for (const id of clientUuids) {
|
if (row?.state === "rejected") await db.delete("sales", clientUuid);
|
||||||
const row = await tx.store.get(id);
|
}
|
||||||
if (row) {
|
|
||||||
row.synced = true;
|
async function applyResults(results: SaleResult[]) {
|
||||||
await tx.store.put(row);
|
const db = await getDb();
|
||||||
|
const tx = db.transaction("sales", "readwrite");
|
||||||
|
for (const r of results) {
|
||||||
|
const row = await tx.store.get(r.clientUuid);
|
||||||
|
if (!row) continue;
|
||||||
|
if (r.status === "rejected") {
|
||||||
|
row.state = "rejected";
|
||||||
|
row.error = r.error;
|
||||||
|
} else {
|
||||||
|
row.state = "synced";
|
||||||
|
row.syncedAt = new Date().toISOString();
|
||||||
|
delete row.error;
|
||||||
}
|
}
|
||||||
|
await tx.store.put(row);
|
||||||
}
|
}
|
||||||
await tx.done;
|
await tx.done;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function flushQueue(): Promise<{ synced: number; failed: number }> {
|
function toPayload(s: QueuedSale) {
|
||||||
const pending = await getPendingSales();
|
return {
|
||||||
if (pending.length === 0) return { synced: 0, failed: 0 };
|
clientUuid: s.clientUuid,
|
||||||
|
staffId: s.userId,
|
||||||
|
branchId: s.branchId,
|
||||||
|
customerName: s.customerName || undefined,
|
||||||
|
customerPhone: s.customerPhone || undefined,
|
||||||
|
clientSubmittedAt: s.clientSubmittedAt,
|
||||||
|
lines: s.lines.map((l) => ({
|
||||||
|
productId: l.productId,
|
||||||
|
quantity: l.quantity,
|
||||||
|
// Adopted legacy rows never recorded a price (0); don't flag those as mismatches.
|
||||||
|
unitPrice: l.unitPrice > 0 ? l.unitPrice : undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
/**
|
||||||
const result = await apiFetch<{ inserted: { clientUuid: string }[] }>("/api/transactions/cash", {
|
* Pre-v2 rows had no owner. They are adopted by the first cashier who
|
||||||
method: "POST",
|
* syncs on this device — exactly what the old terminal did with them.
|
||||||
body: JSON.stringify({ sales: pending.map((p) => ({ ...p, synced: undefined })) }),
|
*/
|
||||||
|
async function adoptLegacySales(owner: QueueOwner) {
|
||||||
|
if (!owner.branchId) return;
|
||||||
|
const db = await getDb();
|
||||||
|
const legacy = (await db.getAll("cash_sales")).filter((s) => !s.synced);
|
||||||
|
for (const s of legacy) {
|
||||||
|
await db.put("sales", {
|
||||||
|
clientUuid: s.clientUuid,
|
||||||
|
userId: owner.id,
|
||||||
|
businessId: owner.businessId ?? "",
|
||||||
|
branchId: owner.branchId,
|
||||||
|
customerName: s.customerName,
|
||||||
|
customerPhone: s.customerPhone,
|
||||||
|
lines: [{ productId: s.productId, label: "Sale", quantity: s.quantity, unitPrice: 0 }],
|
||||||
|
total: 0,
|
||||||
|
clientSubmittedAt: s.clientSubmittedAt,
|
||||||
|
state: "pending",
|
||||||
});
|
});
|
||||||
const syncedIds = result.inserted.map((r) => r.clientUuid);
|
}
|
||||||
await markSynced(syncedIds);
|
await db.clear("cash_sales");
|
||||||
return { synced: syncedIds.length, failed: pending.length - syncedIds.length };
|
}
|
||||||
} catch {
|
|
||||||
return { synced: 0, failed: pending.length };
|
async function pruneSynced(userId: string) {
|
||||||
|
const db = await getDb();
|
||||||
|
const cutoff = Date.now() - KEEP_SYNCED_MS;
|
||||||
|
for (const row of await salesFor(userId)) {
|
||||||
|
if (row.state === "synced" && row.syncedAt && Date.parse(row.syncedAt) < cutoff) {
|
||||||
|
await db.delete("sales", row.clientUuid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerOfflineSync(onFlush?: (r: { synced: number; failed: number }) => void) {
|
export interface FlushResult {
|
||||||
|
synced: number;
|
||||||
|
rejected: number;
|
||||||
|
pending: number;
|
||||||
|
offline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let flushInFlight: Promise<FlushResult> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send this cashier's pending sales in chunks the server accepts. Stops at
|
||||||
|
* the first network/auth failure (everything unsent stays pending).
|
||||||
|
*/
|
||||||
|
export function flushQueue(owner: QueueOwner): Promise<FlushResult> {
|
||||||
|
if (!flushInFlight) {
|
||||||
|
flushInFlight = doFlush(owner).finally(() => {
|
||||||
|
flushInFlight = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return flushInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFlush(owner: QueueOwner): Promise<FlushResult> {
|
||||||
|
await adoptLegacySales(owner);
|
||||||
|
const pending = (await salesFor(owner.id))
|
||||||
|
.filter((s) => s.state === "pending")
|
||||||
|
.sort((a, b) => a.clientSubmittedAt.localeCompare(b.clientSubmittedAt));
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let rejected = 0;
|
||||||
|
let offline = false;
|
||||||
|
if (pending.length === 0) {
|
||||||
|
// Nothing to send: still find out whether the server is reachable, so
|
||||||
|
// the terminal's Online/Offline badge is truthful.
|
||||||
|
offline = !(await fetch("/api/health", { cache: "no-store" }).then((r) => r.ok).catch(() => false));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < pending.length; i += MAX_SALES_PER_REQUEST) {
|
||||||
|
const chunk = pending.slice(i, i + MAX_SALES_PER_REQUEST);
|
||||||
|
try {
|
||||||
|
const { results } = await apiFetch<{ results: SaleResult[] }>("/api/transactions/cash", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ sales: chunk.map(toPayload) }),
|
||||||
|
});
|
||||||
|
await applyResults(results);
|
||||||
|
synced += results.filter((r) => r.status !== "rejected").length;
|
||||||
|
rejected += results.filter((r) => r.status === "rejected").length;
|
||||||
|
} catch (err) {
|
||||||
|
// 401/403: signed out or blocked — keep everything for later.
|
||||||
|
// Network error: offline — same. Nothing is ever dropped here.
|
||||||
|
offline = !(err instanceof ApiError);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pruneSynced(owner.id);
|
||||||
|
const counts = await queueCounts(owner.id);
|
||||||
|
return { synced, rejected, pending: counts.pending, offline };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush on reconnect and every 30s while online. Returns an unsubscribe. */
|
||||||
|
export function registerOfflineSync(owner: QueueOwner, onFlush?: (r: FlushResult) => void) {
|
||||||
if (typeof window === "undefined") return () => {};
|
if (typeof window === "undefined") return () => {};
|
||||||
|
|
||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
const result = await flushQueue();
|
const result = await flushQueue(owner);
|
||||||
onFlush?.(result);
|
onFlush?.(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+45
-1
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt";
|
import { verifyAccessToken, AccessTokenPayload } from "@/lib/server/jwt";
|
||||||
import { UserRole } from "@/lib/server/models/User";
|
import { UserRole } from "@/lib/server/models/User";
|
||||||
|
|
||||||
@@ -27,6 +28,49 @@ export function resolveBranchScope(auth: AccessTokenPayload, requestedBranchId?:
|
|||||||
return auth.branchId;
|
return auth.branchId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MANAGERS: UserRole[] = ["BRANCH_MANAGER", "SUPER_ADMIN"];
|
||||||
|
|
||||||
|
type Gate = { ok: true; auth: AccessTokenPayload } | { ok: false; response: NextResponse };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate the request and, when `roles` is given, authorize it.
|
||||||
|
* 401 = not signed in; 403 = signed in with the wrong role. Every route
|
||||||
|
* that limits roles must use this rather than relying on the admin pages
|
||||||
|
* redirecting — the PRD requires enforcement on the backend too.
|
||||||
|
*/
|
||||||
|
export function authorize(req: NextRequest, roles?: UserRole[]): Gate {
|
||||||
|
const auth = getAuth(req);
|
||||||
|
if (!auth) {
|
||||||
|
return { ok: false, response: NextResponse.json({ error: "Not authenticated" }, { status: 401 }) };
|
||||||
|
}
|
||||||
|
if (roles && !roles.includes(auth.role)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
response: NextResponse.json({ error: "You do not have permission to do this" }, { status: 403 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, auth };
|
||||||
|
}
|
||||||
|
|
||||||
|
type BranchParam = { ok: true; branchId: string | null } | { ok: false; response: NextResponse };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the `branchId` query parameter for a read route: owners may pick
|
||||||
|
* a branch or ALL, everyone else is pinned to their own branch. Rejects a
|
||||||
|
* malformed id with 400 instead of letting ObjectId() throw a 500.
|
||||||
|
*/
|
||||||
|
export function branchParam(auth: AccessTokenPayload, raw: string | null | undefined): BranchParam {
|
||||||
|
const branchId = resolveBranchScope(auth, raw);
|
||||||
|
if (branchId && !isValidObjectId(branchId)) {
|
||||||
|
return { ok: false, response: NextResponse.json({ error: "Invalid branchId" }, { status: 400 }) };
|
||||||
|
}
|
||||||
|
return { ok: true, branchId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function badRequest(message: string): NextResponse {
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
export function isProd(): boolean {
|
export function isProd(): boolean {
|
||||||
return process.env.NODE_ENV === "production";
|
return process.env.NODE_ENV === "production";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// The business day for every tenant is the Kenyan calendar day
|
||||||
|
// (Africa/Nairobi, EAT). EAT is a fixed UTC+3 with no daylight saving, so a
|
||||||
|
// constant offset is exact — no timezone database needed.
|
||||||
|
//
|
||||||
|
// Never use `new Date().setHours(0)` or `toISOString().slice(0, 10)` for
|
||||||
|
// "today" on the server: on Vercel the server clock is UTC, which would
|
||||||
|
// start the business day at 03:00 EAT and push early-morning sales onto
|
||||||
|
// the previous day.
|
||||||
|
|
||||||
|
const EAT_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/** Today's business day as YYYY-MM-DD. */
|
||||||
|
export function todayNairobi(now: Date = new Date()): string {
|
||||||
|
return new Date(now.getTime() + EAT_OFFSET_MS).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBusinessDate(value: string): boolean {
|
||||||
|
if (!DATE_RE.test(value)) return false;
|
||||||
|
const d = new Date(value + "T00:00:00.000Z");
|
||||||
|
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The UTC instants [start, end) covering one business day. */
|
||||||
|
export function dayRangeNairobi(date: string): { start: Date; end: Date } {
|
||||||
|
if (!isBusinessDate(date)) throw new Error(`Invalid business date: ${date}`);
|
||||||
|
const start = new Date(new Date(date + "T00:00:00.000Z").getTime() - EAT_OFFSET_MS);
|
||||||
|
return { start, end: new Date(start.getTime() + DAY_MS) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start of the business day containing `now`. */
|
||||||
|
export function startOfTodayNairobi(now: Date = new Date()): Date {
|
||||||
|
return dayRangeNairobi(todayNairobi(now)).start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start of the current business month (1st, 00:00 EAT). */
|
||||||
|
export function startOfMonthNairobi(now: Date = new Date()): Date {
|
||||||
|
return dayRangeNairobi(todayNairobi(now).slice(0, 8) + "01").start;
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import mongoose, { Types } from "mongoose";
|
||||||
|
import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
|
||||||
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cost of goods sold, first-in-first-out, per branch.
|
||||||
|
*
|
||||||
|
* The stock ledger is replayed in order. Each branch keeps, per product, a
|
||||||
|
* queue of cost layers (quantity at a unit cost). Stock coming in adds a
|
||||||
|
* layer; stock going out takes from the oldest layers first:
|
||||||
|
*
|
||||||
|
* PURCHASE in, at the purchase's unit cost
|
||||||
|
* TRANSFER_IN in, at the cost of the layers the sending branch gave up
|
||||||
|
* ADJUSTMENT + in (opening stock, a count that found more), at an
|
||||||
|
* estimated cost: the latest purchase price for the product
|
||||||
|
* at that time, or the first one after it if none yet
|
||||||
|
* SALE out: its cost is cost of goods sold
|
||||||
|
* TRANSFER_OUT out: its cost travels with the stock to the other branch
|
||||||
|
* ADJUSTMENT - out (breakage, a count that found less): its cost is a
|
||||||
|
* stock loss, reported separately from cost of goods
|
||||||
|
*
|
||||||
|
* Stock can go negative (an offline sale recorded before the stock it came
|
||||||
|
* from). The units sold short wait as a deficit and are costed by the next
|
||||||
|
* stock that arrives at that branch, which is where they must have come
|
||||||
|
* from. Units still uncosted at the end are reported as unitsWithoutCost.
|
||||||
|
*
|
||||||
|
* The replay reads the whole history, in order, so a sale is costed against
|
||||||
|
* what was actually on hand when it happened, whatever period is being
|
||||||
|
* reported; only events inside the period (and branch) are added up. A sale
|
||||||
|
* made short in the period is costed by stock that arrived later, even
|
||||||
|
* after the period ended.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Id = Types.ObjectId | string;
|
||||||
|
|
||||||
|
interface Layer {
|
||||||
|
qty: number;
|
||||||
|
unitCost: number | null; // null: cost unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Deficit {
|
||||||
|
qty: number;
|
||||||
|
kind: "sale" | "loss" | "transfer";
|
||||||
|
counts: boolean; // inside the reported period and branch
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
layers: Layer[];
|
||||||
|
deficits: Deficit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostOfGoods {
|
||||||
|
costOfGoodsSold: number;
|
||||||
|
unitsCosted: number; // units sold in the period that have a cost
|
||||||
|
unitsWithoutCost: number; // units sold in the period whose cost is unknown
|
||||||
|
stockLosses: number; // cost of stock written off by counts or adjustments in the period
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process transfers' outgoing side before the incoming side, so the cost is known.
|
||||||
|
const TYPE_ORDER: Record<MovementType, number> = {
|
||||||
|
PURCHASE: 0,
|
||||||
|
ADJUSTMENT: 1,
|
||||||
|
TRANSFER_OUT: 2,
|
||||||
|
TRANSFER_IN: 3,
|
||||||
|
SALE: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/** Take `qty` units from the front of `layers`; returns what was taken and the shortfall. */
|
||||||
|
function take(layers: Layer[], qty: number): { taken: Layer[]; short: number } {
|
||||||
|
const taken: Layer[] = [];
|
||||||
|
let need = qty;
|
||||||
|
while (need > 0 && layers.length > 0) {
|
||||||
|
const head = layers[0];
|
||||||
|
const n = Math.min(head.qty, need);
|
||||||
|
taken.push({ qty: n, unitCost: head.unitCost });
|
||||||
|
head.qty -= n;
|
||||||
|
need -= n;
|
||||||
|
if (head.qty === 0) layers.shift();
|
||||||
|
}
|
||||||
|
return { taken, short: need };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function costOfGoodsSold(
|
||||||
|
businessId: Id,
|
||||||
|
period: { from: Date; to: Date; branchId?: Id | null }
|
||||||
|
): Promise<CostOfGoods> {
|
||||||
|
const biz = new mongoose.Types.ObjectId(businessId.toString());
|
||||||
|
const branchFilter = period.branchId ? period.branchId.toString() : null;
|
||||||
|
|
||||||
|
const [movements, purchases] = await Promise.all([
|
||||||
|
StockMovement.find({ businessId: biz })
|
||||||
|
.select("branchId productId type quantity refId createdAt")
|
||||||
|
.sort({ createdAt: 1, _id: 1 })
|
||||||
|
.lean(),
|
||||||
|
Purchase.find({ businessId: biz, productId: { $ne: null } })
|
||||||
|
.select("productId unitCost createdAt")
|
||||||
|
.sort({ createdAt: 1 })
|
||||||
|
.lean(),
|
||||||
|
]);
|
||||||
|
movements.sort(
|
||||||
|
(a, b) => a.createdAt.getTime() - b.createdAt.getTime() || TYPE_ORDER[a.type] - TYPE_ORDER[b.type]
|
||||||
|
);
|
||||||
|
|
||||||
|
const purchaseCost = new Map(purchases.map((p) => [p._id.toString(), p.unitCost]));
|
||||||
|
const priceHistory = new Map<string, { at: number; unitCost: number }[]>();
|
||||||
|
for (const p of purchases) {
|
||||||
|
const key = p.productId!.toString();
|
||||||
|
if (!priceHistory.has(key)) priceHistory.set(key, []);
|
||||||
|
priceHistory.get(key)!.push({ at: p.createdAt.getTime(), unitCost: p.unitCost });
|
||||||
|
}
|
||||||
|
const estimateCost = (productId: string, at: number): number | null => {
|
||||||
|
const history = priceHistory.get(productId);
|
||||||
|
if (!history || history.length === 0) return null;
|
||||||
|
let latest: number | null = null;
|
||||||
|
for (const h of history) {
|
||||||
|
if (h.at <= at) latest = h.unitCost;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return latest ?? history[0].unitCost;
|
||||||
|
};
|
||||||
|
|
||||||
|
const positions = new Map<string, Position>();
|
||||||
|
const position = (branch: string, product: string) => {
|
||||||
|
const key = `${branch}:${product}`;
|
||||||
|
if (!positions.has(key)) positions.set(key, { layers: [], deficits: [] });
|
||||||
|
return positions.get(key)!;
|
||||||
|
};
|
||||||
|
const inTransit = new Map<string, Layer[]>(); // transfer refId -> layers given up by the sender
|
||||||
|
|
||||||
|
const result = { costOfGoodsSold: 0, unitsCosted: 0, unitsWithoutCost: 0, stockLosses: 0 };
|
||||||
|
const charge = (kind: Deficit["kind"], layers: Layer[]) => {
|
||||||
|
for (const l of layers) {
|
||||||
|
if (kind === "sale") {
|
||||||
|
if (l.unitCost === null) result.unitsWithoutCost += l.qty;
|
||||||
|
else {
|
||||||
|
result.costOfGoodsSold += l.qty * l.unitCost;
|
||||||
|
result.unitsCosted += l.qty;
|
||||||
|
}
|
||||||
|
} else if (kind === "loss" && l.unitCost !== null) {
|
||||||
|
result.stockLosses += l.qty * l.unitCost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const m of movements) {
|
||||||
|
const branch = m.branchId.toString();
|
||||||
|
const product = m.productId.toString();
|
||||||
|
const pos = position(branch, product);
|
||||||
|
const at = m.createdAt.getTime();
|
||||||
|
const counts =
|
||||||
|
at >= period.from.getTime() && at < period.to.getTime() && (!branchFilter || branch === branchFilter);
|
||||||
|
|
||||||
|
if (m.quantity > 0) {
|
||||||
|
let incoming: Layer[];
|
||||||
|
if (m.type === "PURCHASE") incoming = [{ qty: m.quantity, unitCost: purchaseCost.get(m.refId) ?? null }];
|
||||||
|
else if (m.type === "TRANSFER_IN") incoming = (inTransit.get(m.refId) ?? [{ qty: m.quantity, unitCost: null }]).map((l) => ({ ...l }));
|
||||||
|
else incoming = [{ qty: m.quantity, unitCost: estimateCost(product, at) }];
|
||||||
|
|
||||||
|
// Stock that was already sold short is covered first, at this stock's cost.
|
||||||
|
while (pos.deficits.length > 0 && incoming.length > 0) {
|
||||||
|
const d = pos.deficits[0];
|
||||||
|
const { taken, short } = take(incoming, d.qty);
|
||||||
|
if (d.counts) charge(d.kind, taken);
|
||||||
|
d.qty = short;
|
||||||
|
if (d.qty === 0) pos.deficits.shift();
|
||||||
|
}
|
||||||
|
pos.layers.push(...incoming);
|
||||||
|
} else if (m.quantity < 0) {
|
||||||
|
const kind: Deficit["kind"] = m.type === "SALE" ? "sale" : m.type === "TRANSFER_OUT" ? "transfer" : "loss";
|
||||||
|
const { taken, short } = take(pos.layers, -m.quantity);
|
||||||
|
if (kind === "transfer") {
|
||||||
|
// The receiving branch inherits this cost; any shortfall arrives with no known cost.
|
||||||
|
inTransit.set(m.refId, short > 0 ? [...taken, { qty: short, unitCost: null }] : taken);
|
||||||
|
} else if (counts) {
|
||||||
|
charge(kind, taken);
|
||||||
|
}
|
||||||
|
if (short > 0) pos.deficits.push({ qty: short, kind, counts: counts && kind !== "transfer" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short sales in the period that no later stock has covered yet.
|
||||||
|
for (const pos of positions.values()) {
|
||||||
|
for (const d of pos.deficits) if (d.counts && d.kind === "sale") result.unitsWithoutCost += d.qty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
costOfGoodsSold: round2(result.costOfGoodsSold),
|
||||||
|
unitsCosted: result.unitsCosted,
|
||||||
|
unitsWithoutCost: result.unitsWithoutCost,
|
||||||
|
stockLosses: round2(result.stockLosses),
|
||||||
|
};
|
||||||
|
}
|
||||||
+72
-19
@@ -1,26 +1,60 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const schema = z.object({
|
const booleanFlag = z
|
||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
.enum(["true", "false", "1", "0", ""])
|
||||||
MONGODB_URI: z.string().min(1),
|
.optional()
|
||||||
|
.transform((v) => v === "true" || v === "1");
|
||||||
|
|
||||||
JWT_ACCESS_SECRET: z.string().min(16),
|
const schema = z
|
||||||
JWT_REFRESH_SECRET: z.string().min(16),
|
.object({
|
||||||
JWT_ACCESS_TTL: z.string().default("15m"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||||
JWT_REFRESH_TTL: z.string().default("30d"),
|
MONGODB_URI: z.string().min(1),
|
||||||
DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
|
|
||||||
|
|
||||||
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"),
|
JWT_ACCESS_SECRET: z.string().min(16),
|
||||||
DARAJA_CONSUMER_KEY: z.string().min(1),
|
JWT_REFRESH_SECRET: z.string().min(16),
|
||||||
DARAJA_CONSUMER_SECRET: z.string().min(1),
|
JWT_ACCESS_TTL: z.string().default("15m"),
|
||||||
DARAJA_SHORTCODE: z.string().min(1),
|
JWT_REFRESH_TTL: z.string().default("30d"),
|
||||||
DARAJA_PASSKEY: z.string().min(1),
|
DELIVERY_MAX_DEVICES: z.coerce.number().default(2),
|
||||||
DARAJA_TRANSACTION_TYPE: z
|
|
||||||
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"])
|
// M-Pesa STK Push is out of scope for the pilot (PRD section 4), so it
|
||||||
.default("CustomerPayBillOnline"),
|
// is OFF unless explicitly enabled. When off, the Daraja variables are
|
||||||
DARAJA_CALLBACK_URL: z.string().url(),
|
// not required and every /api/mpesa route answers 404.
|
||||||
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(),
|
FEATURE_MPESA: booleanFlag,
|
||||||
});
|
|
||||||
|
DARAJA_ENV: z.enum(["sandbox", "production"]).default("sandbox"),
|
||||||
|
DARAJA_CONSUMER_KEY: z.string().optional(),
|
||||||
|
DARAJA_CONSUMER_SECRET: z.string().optional(),
|
||||||
|
DARAJA_SHORTCODE: z.string().optional(),
|
||||||
|
DARAJA_PASSKEY: z.string().optional(),
|
||||||
|
DARAJA_TRANSACTION_TYPE: z
|
||||||
|
.enum(["CustomerPayBillOnline", "CustomerBuyGoodsOnline"])
|
||||||
|
.default("CustomerPayBillOnline"),
|
||||||
|
DARAJA_CALLBACK_URL: z.string().url().optional(),
|
||||||
|
DARAJA_CALLBACK_IP_ALLOWLIST: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((env, ctx) => {
|
||||||
|
if (!env.FEATURE_MPESA) return;
|
||||||
|
const required = [
|
||||||
|
"DARAJA_CONSUMER_KEY",
|
||||||
|
"DARAJA_CONSUMER_SECRET",
|
||||||
|
"DARAJA_SHORTCODE",
|
||||||
|
"DARAJA_PASSKEY",
|
||||||
|
"DARAJA_CALLBACK_URL",
|
||||||
|
] as const;
|
||||||
|
for (const key of required) {
|
||||||
|
if (!env[key]) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: "required when FEATURE_MPESA=true" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An open callback endpoint lets anyone mark a pending sale as paid.
|
||||||
|
if (env.NODE_ENV === "production" && !env.DARAJA_CALLBACK_IP_ALLOWLIST) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["DARAJA_CALLBACK_IP_ALLOWLIST"],
|
||||||
|
message: "required in production when FEATURE_MPESA=true",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type ServerEnv = z.infer<typeof schema>;
|
type ServerEnv = z.infer<typeof schema>;
|
||||||
let cached: ServerEnv | null = null;
|
let cached: ServerEnv | null = null;
|
||||||
@@ -35,3 +69,22 @@ export function getEnv(): ServerEnv {
|
|||||||
cached = parsed.data;
|
cached = parsed.data;
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMpesaEnabled(): boolean {
|
||||||
|
return getEnv().FEATURE_MPESA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Daraja settings, only valid when FEATURE_MPESA is on (validated above). */
|
||||||
|
export function getDarajaEnv() {
|
||||||
|
const env = getEnv();
|
||||||
|
if (!env.FEATURE_MPESA) throw new Error("M-Pesa is disabled (FEATURE_MPESA is not true)");
|
||||||
|
return {
|
||||||
|
DARAJA_ENV: env.DARAJA_ENV,
|
||||||
|
DARAJA_CONSUMER_KEY: env.DARAJA_CONSUMER_KEY!,
|
||||||
|
DARAJA_CONSUMER_SECRET: env.DARAJA_CONSUMER_SECRET!,
|
||||||
|
DARAJA_SHORTCODE: env.DARAJA_SHORTCODE!,
|
||||||
|
DARAJA_PASSKEY: env.DARAJA_PASSKEY!,
|
||||||
|
DARAJA_TRANSACTION_TYPE: env.DARAJA_TRANSACTION_TYPE,
|
||||||
|
DARAJA_CALLBACK_URL: env.DARAJA_CALLBACK_URL!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import type { IUser } from "@/lib/server/models/User";
|
||||||
|
|
||||||
|
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
/** Reset links are shorter-lived: they can take over an account that is already in use. */
|
||||||
|
export const RESET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type LinkPurpose = "invite" | "reset";
|
||||||
|
|
||||||
|
export function hashInviteToken(token: string): string {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A password nobody knows, for an account whose owner has not accepted their invite yet. */
|
||||||
|
export async function unusablePasswordHash(): Promise<string> {
|
||||||
|
return bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the account is waiting for its first password (not a reset). */
|
||||||
|
export function invitePending(user: Pick<IUser, "inviteExpiresAt" | "invitePurpose">): boolean {
|
||||||
|
return !!user.inviteExpiresAt && user.invitePurpose !== "reset";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the user a fresh one-time password link (any earlier link stops
|
||||||
|
* working) and return it to send them. The caller saves the user.
|
||||||
|
*/
|
||||||
|
export function issueInvite(user: IUser, req: NextRequest, purpose: LinkPurpose = "invite"): string {
|
||||||
|
const token = crypto.randomBytes(32).toString("base64url");
|
||||||
|
user.inviteTokenHash = hashInviteToken(token);
|
||||||
|
user.inviteExpiresAt = new Date(Date.now() + (purpose === "reset" ? RESET_TTL_MS : INVITE_TTL_MS));
|
||||||
|
user.invitePurpose = purpose;
|
||||||
|
const origin = process.env.APP_URL?.replace(/\/$/, "") || req.nextUrl.origin;
|
||||||
|
return `${origin}/invite/${token}`;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export interface IBranch extends Document {
|
export interface IBranch extends Document {
|
||||||
@@ -6,6 +7,12 @@ export interface IBranch extends Document {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
locationCity: string;
|
locationCity: string;
|
||||||
managerId: Types.ObjectId | null;
|
managerId: Types.ObjectId | null;
|
||||||
|
// A deactivated branch keeps all its sales, purchases and stock history
|
||||||
|
// and still appears in reports for past periods, but takes no new staff,
|
||||||
|
// transfers or purchases and does not count towards the plan's limit.
|
||||||
|
// Rows created before this field existed have no value: treat as active
|
||||||
|
// (query with `isActive: { $ne: false }`).
|
||||||
|
isActive: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -16,9 +23,15 @@ const branchSchema = new Schema<IBranch>(
|
|||||||
branchName: { type: String, required: true, trim: true },
|
branchName: { type: String, required: true, trim: true },
|
||||||
locationCity: { type: String, required: true, trim: true },
|
locationCity: { type: String, required: true, trim: true },
|
||||||
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
||||||
|
isActive: { type: Boolean, default: true },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Filter for branches that are in use. */
|
||||||
|
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
|
||||||
|
|
||||||
|
branchSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Branch =
|
export const Branch =
|
||||||
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export interface IDailyInventory extends Document {
|
export interface IDailyInventory extends Document {
|
||||||
@@ -40,6 +41,8 @@ dailyInventorySchema.index({ branchId: 1, date: 1 });
|
|||||||
// One ledger row per branch+product+day.
|
// One ledger row per branch+product+day.
|
||||||
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
|
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
|
||||||
|
|
||||||
|
dailyInventorySchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const DailyInventory =
|
export const DailyInventory =
|
||||||
(models.DailyInventory as Model<IDailyInventory>) ||
|
(models.DailyInventory as Model<IDailyInventory>) ||
|
||||||
model<IDailyInventory>("DailyInventory", dailyInventorySchema);
|
model<IDailyInventory>("DailyInventory", dailyInventorySchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
// Business expense categories. Kept as a constrained string set so the UI
|
// Business expense categories. Kept as a constrained string set so the UI
|
||||||
@@ -46,5 +47,7 @@ expenseSchema.index({ category: 1 });
|
|||||||
|
|
||||||
// `models.Expense` guard avoids OverwriteModelError on serverless warm
|
// `models.Expense` guard avoids OverwriteModelError on serverless warm
|
||||||
// re-invocations where the module is evaluated more than once.
|
// re-invocations where the module is evaluated more than once.
|
||||||
|
expenseSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Expense =
|
export const Expense =
|
||||||
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
|
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export interface IProduct extends Document {
|
export interface IProduct extends Document {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
businessId: Types.ObjectId; // tenant this product belongs to
|
businessId: Types.ObjectId; // tenant this product belongs to
|
||||||
label: string;
|
label: string;
|
||||||
|
// Size measured in `unit` (litres for water, a count for side items).
|
||||||
|
// The field keeps its historical name so existing data needs no migration.
|
||||||
sizeLiters: number;
|
sizeLiters: number;
|
||||||
unit: string; // display unit, e.g. "L" for water, "pcs" / "tray" for eggs
|
unit: string; // see PRODUCT_UNITS in src/lib/units.ts: "L", "pcs", "tray"...
|
||||||
category: string; // e.g. "Water", "Eggs"
|
category: string; // e.g. "Water", "Eggs"
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
lowStockThreshold: number; // a branch at or below this many units shows a low-stock warning
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -24,10 +28,13 @@ const productSchema = new Schema<IProduct>(
|
|||||||
unitPrice: { type: Number, required: true, min: 0 },
|
unitPrice: { type: Number, required: true, min: 0 },
|
||||||
sortOrder: { type: Number, default: 0 },
|
sortOrder: { type: Number, default: 0 },
|
||||||
isActive: { type: Boolean, default: true, index: true },
|
isActive: { type: Boolean, default: true, index: true },
|
||||||
|
lowStockThreshold: { type: Number, default: 10, min: 0 },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
productSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Product =
|
export const Product =
|
||||||
(models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema);
|
(models.Product as Model<IProduct>) || model<IProduct>("Product", productSchema);
|
||||||
|
|
||||||
@@ -37,6 +44,7 @@ export interface IBranchPricing extends Document {
|
|||||||
branchId: Types.ObjectId;
|
branchId: Types.ObjectId;
|
||||||
productId: Types.ObjectId;
|
productId: Types.ObjectId;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
updatedBy: Types.ObjectId | null;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +54,14 @@ const branchPricingSchema = new Schema<IBranchPricing>(
|
|||||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
||||||
unitPrice: { type: Number, required: true, min: 0 },
|
unitPrice: { type: Number, required: true, min: 0 },
|
||||||
|
updatedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
||||||
},
|
},
|
||||||
{ timestamps: { createdAt: false, updatedAt: true } }
|
{ timestamps: { createdAt: false, updatedAt: true } }
|
||||||
);
|
);
|
||||||
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
|
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
|
||||||
|
|
||||||
|
branchPricingSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const BranchPricing =
|
export const BranchPricing =
|
||||||
(models.BranchPricing as Model<IBranchPricing>) ||
|
(models.BranchPricing as Model<IBranchPricing>) ||
|
||||||
model<IBranchPricing>("BranchPricing", branchPricingSchema);
|
model<IBranchPricing>("BranchPricing", branchPricingSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
// A recorded stock purchase from a supplier. Completing a purchase is what
|
// A recorded stock purchase from a supplier. Completing a purchase is what
|
||||||
@@ -40,5 +41,7 @@ const purchaseSchema = new Schema<IPurchase>(
|
|||||||
purchaseSchema.index({ branchId: 1, createdAt: -1 });
|
purchaseSchema.index({ branchId: 1, createdAt: -1 });
|
||||||
purchaseSchema.index({ productId: 1 });
|
purchaseSchema.index({ productId: 1 });
|
||||||
|
|
||||||
|
purchaseSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Purchase =
|
export const Purchase =
|
||||||
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
|
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Schema, model, models, Model } from "mongoose";
|
||||||
|
|
||||||
|
export interface IStockLock {
|
||||||
|
_id: string; // `${businessId}:${branchId}:${productId}`
|
||||||
|
owner: string; // random id of the request holding the lock
|
||||||
|
lockedUntil: Date; // lease expiry: a crashed request cannot hold the lock forever
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short lease on one product's stock at one branch, held while a request
|
||||||
|
* reads the current balance and writes a movement that depends on it (see
|
||||||
|
* withStockLock in src/lib/server/stock.ts). Rows are deleted on release;
|
||||||
|
* an expired row is simply taken over by the next request.
|
||||||
|
*/
|
||||||
|
const stockLockSchema = new Schema<IStockLock>(
|
||||||
|
{
|
||||||
|
_id: { type: String, required: true },
|
||||||
|
owner: { type: String, required: true },
|
||||||
|
lockedUntil: { type: Date, required: true },
|
||||||
|
},
|
||||||
|
{ versionKey: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
export const StockLock =
|
||||||
|
(models.StockLock as Model<IStockLock>) || model<IStockLock>("StockLock", stockLockSchema);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
|
export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const;
|
||||||
|
export type MovementType = (typeof MOVEMENT_TYPES)[number];
|
||||||
|
|
||||||
|
export interface IStockMovement extends Document {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
businessId: Types.ObjectId;
|
||||||
|
branchId: Types.ObjectId;
|
||||||
|
productId: Types.ObjectId;
|
||||||
|
type: MovementType;
|
||||||
|
quantity: number; // signed: + adds stock to the branch, - removes it
|
||||||
|
refId: string; // the document that caused it (transaction, purchase, transfer, adjustment)
|
||||||
|
note: string | null;
|
||||||
|
recordedBy: Types.ObjectId | null;
|
||||||
|
createdAt: Date; // authoritative — server clock
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stock ledger. A branch's stock of a product is the SUM of its
|
||||||
|
* movements — there is no separately stored balance that could drift out
|
||||||
|
* of step with history.
|
||||||
|
*
|
||||||
|
* Every movement names the document that caused it, and (type, refId) is
|
||||||
|
* unique. Writing a movement is therefore idempotent: replaying an offline
|
||||||
|
* sale, retrying a transfer, or a second M-Pesa callback can never deduct
|
||||||
|
* or add the same stock twice. A transfer writes two movements with the
|
||||||
|
* same refId but different types (TRANSFER_OUT, TRANSFER_IN).
|
||||||
|
*/
|
||||||
|
const stockMovementSchema = new Schema<IStockMovement>(
|
||||||
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true },
|
||||||
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
|
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
||||||
|
type: { type: String, enum: MOVEMENT_TYPES, required: true },
|
||||||
|
quantity: { type: Number, required: true },
|
||||||
|
refId: { type: String, required: true },
|
||||||
|
note: { type: String, default: null, trim: true },
|
||||||
|
recordedBy: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
||||||
|
},
|
||||||
|
{ timestamps: { createdAt: true, updatedAt: false } }
|
||||||
|
);
|
||||||
|
|
||||||
|
stockMovementSchema.index({ type: 1, refId: 1 }, { unique: true });
|
||||||
|
// Stock-level aggregation and the per-branch movement history.
|
||||||
|
stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 });
|
||||||
|
stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
stockMovementSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
|
export const StockMovement =
|
||||||
|
(models.StockMovement as Model<IStockMovement>) ||
|
||||||
|
model<IStockMovement>("StockMovement", stockMovementSchema);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
|
export interface IStockTransfer extends Document {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
businessId: Types.ObjectId;
|
||||||
|
fromBranchId: Types.ObjectId;
|
||||||
|
toBranchId: Types.ObjectId;
|
||||||
|
productId: Types.ObjectId;
|
||||||
|
quantity: number;
|
||||||
|
clientUuid: string; // idempotency key from the form, so a double-submit moves stock once
|
||||||
|
note: string | null;
|
||||||
|
recordedBy: Types.ObjectId;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An inter-branch stock transfer. The stock itself moves through two
|
||||||
|
* StockMovement rows (TRANSFER_OUT at the source, TRANSFER_IN at the
|
||||||
|
* destination) whose refId is this transfer's id.
|
||||||
|
*/
|
||||||
|
const stockTransferSchema = new Schema<IStockTransfer>(
|
||||||
|
{
|
||||||
|
businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true },
|
||||||
|
fromBranchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
|
toBranchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
|
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
||||||
|
quantity: { type: Number, required: true, min: 1 },
|
||||||
|
clientUuid: { type: String, required: true },
|
||||||
|
note: { type: String, default: null, trim: true },
|
||||||
|
recordedBy: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
||||||
|
},
|
||||||
|
{ timestamps: { createdAt: true, updatedAt: false } }
|
||||||
|
);
|
||||||
|
|
||||||
|
stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true });
|
||||||
|
stockTransferSchema.index({ businessId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
stockTransferSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
|
export const StockTransfer =
|
||||||
|
(models.StockTransfer as Model<IStockTransfer>) ||
|
||||||
|
model<IStockTransfer>("StockTransfer", stockTransferSchema);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export type PaymentMethod = "CASH" | "MPESA";
|
export type PaymentMethod = "CASH" | "MPESA";
|
||||||
@@ -9,7 +10,7 @@ export interface ITransaction extends Document {
|
|||||||
branchId: Types.ObjectId;
|
branchId: Types.ObjectId;
|
||||||
staffId: Types.ObjectId;
|
staffId: Types.ObjectId;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
customerPhone: string;
|
customerPhone: string | null; // optional for cash sales; required for M-Pesa
|
||||||
productId: Types.ObjectId;
|
productId: Types.ObjectId;
|
||||||
sizeLiters: number; // snapshotted at sale time
|
sizeLiters: number; // snapshotted at sale time
|
||||||
quantity: number;
|
quantity: number;
|
||||||
@@ -26,8 +27,15 @@ export interface ITransaction extends Document {
|
|||||||
resultDesc: string | null;
|
resultDesc: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
clientUuid: string; // idempotency key generated on-device
|
clientUuid: string; // idempotency key for this line: `${saleId}:${lineIndex}` for cash sales
|
||||||
|
saleId: string | null; // groups the lines of one multi-item sale (the device's sale UUID)
|
||||||
|
lineIndex: number;
|
||||||
syncedFromOffline: boolean;
|
syncedFromOffline: boolean;
|
||||||
|
// What the device showed and collected per unit. The server price stays
|
||||||
|
// authoritative; a difference (e.g. the price changed while the device
|
||||||
|
// was offline) is kept and flagged rather than silently overwritten.
|
||||||
|
clientUnitPrice: number | null;
|
||||||
|
priceMismatch: boolean;
|
||||||
|
|
||||||
// TAMPER PREVENTION: this is what the device *claimed* the time was when
|
// TAMPER PREVENTION: this is what the device *claimed* the time was when
|
||||||
// the sale happened — informational only, e.g. for offline-queue
|
// the sale happened — informational only, e.g. for offline-queue
|
||||||
@@ -50,7 +58,7 @@ const transactionSchema = new Schema<ITransaction>(
|
|||||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true },
|
||||||
staffId: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
staffId: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
||||||
customerName: { type: String, default: null, trim: true },
|
customerName: { type: String, default: null, trim: true },
|
||||||
customerPhone: { type: String, required: true },
|
customerPhone: { type: String, default: null },
|
||||||
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
productId: { type: Schema.Types.ObjectId, ref: "Product", required: true },
|
||||||
sizeLiters: { type: Number, required: true },
|
sizeLiters: { type: Number, required: true },
|
||||||
quantity: { type: Number, required: true, min: 1 },
|
quantity: { type: Number, required: true, min: 1 },
|
||||||
@@ -73,7 +81,11 @@ const transactionSchema = new Schema<ITransaction>(
|
|||||||
},
|
},
|
||||||
|
|
||||||
clientUuid: { type: String, required: true },
|
clientUuid: { type: String, required: true },
|
||||||
|
saleId: { type: String, default: null },
|
||||||
|
lineIndex: { type: Number, default: 0 },
|
||||||
syncedFromOffline: { type: Boolean, default: false },
|
syncedFromOffline: { type: Boolean, default: false },
|
||||||
|
clientUnitPrice: { type: Number, default: null },
|
||||||
|
priceMismatch: { type: Boolean, default: false },
|
||||||
clientSubmittedAt: { type: Date, default: null },
|
clientSubmittedAt: { type: Date, default: null },
|
||||||
},
|
},
|
||||||
{ timestamps: true } // createdAt/updatedAt set from the server's Date.now(), not client input
|
{ timestamps: true } // createdAt/updatedAt set from the server's Date.now(), not client input
|
||||||
@@ -92,6 +104,9 @@ transactionSchema.index({ clientUuid: 1 }, { unique: true });
|
|||||||
transactionSchema.index({ branchId: 1, createdAt: -1 });
|
transactionSchema.index({ branchId: 1, createdAt: -1 });
|
||||||
transactionSchema.index({ businessId: 1, createdAt: -1 });
|
transactionSchema.index({ businessId: 1, createdAt: -1 });
|
||||||
transactionSchema.index({ paymentStatus: 1 });
|
transactionSchema.index({ paymentStatus: 1 });
|
||||||
|
transactionSchema.index({ businessId: 1, saleId: 1 });
|
||||||
|
|
||||||
|
transactionSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Transaction =
|
export const Transaction =
|
||||||
(models.Transaction as Model<ITransaction>) ||
|
(models.Transaction as Model<ITransaction>) ||
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
@@ -10,12 +11,29 @@ export interface IUser extends Document {
|
|||||||
name: string;
|
name: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN (business owner)
|
branchId: Types.ObjectId | null; // null only valid for SUPER_ADMIN (business owner)
|
||||||
|
// Email and phone are unique WITHIN a business, not across the platform,
|
||||||
|
// so one person can work for two businesses (each account has its own
|
||||||
|
// password and role). Login asks which business when the same email and
|
||||||
|
// password match accounts in more than one.
|
||||||
phoneNumber: string; // E.164-ish normalized Kenyan format — kept for contact/M-Pesa reference, not used for login
|
phoneNumber: string; // E.164-ish normalized Kenyan format — kept for contact/M-Pesa reference, not used for login
|
||||||
email: string; // login identifier for every role, via the single /login page
|
email: string; // login identifier for every role, via the single /login page
|
||||||
|
// Platform operator (cross-tenant console). Only set by `npm run seed` or
|
||||||
|
// the identity migration, never through the app, and only honoured while
|
||||||
|
// the email is also listed in PLATFORM_ADMIN_EMAILS.
|
||||||
|
platformAdmin: boolean;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
failedLoginAttempts: number;
|
failedLoginAttempts: number;
|
||||||
lockedUntil: Date | null;
|
lockedUntil: Date | null;
|
||||||
|
// A one-time link through which the person sets their own password.
|
||||||
|
// Only the SHA-256 of the token is stored.
|
||||||
|
// invite: a new account; until accepted it has a random unknown password.
|
||||||
|
// reset: a forgotten password; the old password keeps working until the
|
||||||
|
// link is used, and using it signs the account out everywhere.
|
||||||
|
// Rows from before invitePurpose existed have null, which means invite.
|
||||||
|
inviteTokenHash: string | null;
|
||||||
|
inviteExpiresAt: Date | null;
|
||||||
|
invitePurpose: "invite" | "reset" | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@@ -28,16 +46,27 @@ const userSchema = new Schema<IUser>(
|
|||||||
name: { type: String, required: true, trim: true },
|
name: { type: String, required: true, trim: true },
|
||||||
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
|
role: { type: String, enum: USER_ROLES, required: true, default: "DELIVERY" },
|
||||||
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
|
branchId: { type: Schema.Types.ObjectId, ref: "Branch", default: null },
|
||||||
phoneNumber: { type: String, required: true, unique: true, index: true },
|
phoneNumber: { type: String, required: true },
|
||||||
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
|
email: { type: String, required: true, lowercase: true, trim: true },
|
||||||
|
platformAdmin: { type: Boolean, default: false },
|
||||||
passwordHash: { type: String, required: true, select: false },
|
passwordHash: { type: String, required: true, select: false },
|
||||||
isActive: { type: Boolean, default: true },
|
isActive: { type: Boolean, default: true },
|
||||||
failedLoginAttempts: { type: Number, default: 0 },
|
failedLoginAttempts: { type: Number, default: 0 },
|
||||||
lockedUntil: { type: Date, default: null },
|
lockedUntil: { type: Date, default: null },
|
||||||
|
inviteTokenHash: { type: String, default: null, index: { sparse: true } },
|
||||||
|
inviteExpiresAt: { type: Date, default: null },
|
||||||
|
invitePurpose: { type: String, enum: ["invite", "reset", null], default: null },
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
userSchema.index({ businessId: 1, email: 1 }, { unique: true });
|
||||||
|
userSchema.index({ businessId: 1, phoneNumber: 1 }, { unique: true });
|
||||||
|
// Login looks an email up across businesses. Named so it never clashes with
|
||||||
|
// the old platform-wide unique `email_1` index, which the identity
|
||||||
|
// migration (npm run migrate:identity) drops.
|
||||||
|
userSchema.index({ email: 1 }, { name: "email_lookup" });
|
||||||
|
|
||||||
// Enforce role/branch invariants at the model layer — belt-and-braces
|
// Enforce role/branch invariants at the model layer — belt-and-braces
|
||||||
// alongside API-level validation. Every role authenticates the same way
|
// alongside API-level validation. Every role authenticates the same way
|
||||||
// now (email + password on the single /login page), so there is no
|
// now (email + password on the single /login page), so there is no
|
||||||
@@ -57,5 +86,7 @@ userSchema.methods.comparePassword = async function (password: string): Promise<
|
|||||||
return bcrypt.compare(password, this.passwordHash);
|
return bcrypt.compare(password, this.passwordHash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
userSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const User =
|
export const User =
|
||||||
(models.User as Model<IUser>) || model<IUser>("User", userSchema);
|
(models.User as Model<IUser>) || model<IUser>("User", userSchema);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { getEnv } from "@/lib/server/env";
|
import { getDarajaEnv as getEnv } from "@/lib/server/env";
|
||||||
|
|
||||||
function baseUrl(): string {
|
function baseUrl(): string {
|
||||||
return getEnv().DARAJA_ENV === "production"
|
return getEnv().DARAJA_ENV === "production"
|
||||||
|
|||||||
@@ -26,21 +26,32 @@ import type { NextRequest } from "next/server";
|
|||||||
import { getAuth } from "@/lib/server/auth";
|
import { getAuth } from "@/lib/server/auth";
|
||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this account is a platform operator. Two conditions, because
|
||||||
|
* emails are only unique within a business: any owner can create a staff
|
||||||
|
* account with any email in their own business, so an email match alone
|
||||||
|
* must never grant cross-tenant access. The account must also carry the
|
||||||
|
* platformAdmin flag, which the app never sets (seed/migration only).
|
||||||
|
* Removing an email from PLATFORM_ADMIN_EMAILS revokes access at once.
|
||||||
|
*/
|
||||||
|
export function isPlatformAdmin(user: { email?: string | null; platformAdmin?: boolean | null }): boolean {
|
||||||
|
return user.platformAdmin === true && isPlatformAdminEmail(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve and authorize the current request as a platform operator.
|
* Resolve and authorize the current request as a platform operator.
|
||||||
* Returns the authenticated User's id/email on success, or null if the
|
* Returns the authenticated User's id/email on success, or null if the
|
||||||
* caller is not a platform admin. The DB must already be connected.
|
* caller is not a platform admin. The DB must already be connected.
|
||||||
*
|
|
||||||
* The JWT has no email, so we re-read the User and match its email
|
|
||||||
* against PLATFORM_ADMIN_EMAILS.
|
|
||||||
*/
|
*/
|
||||||
export async function requirePlatformAdmin(
|
export async function requirePlatformAdmin(
|
||||||
req: NextRequest
|
req: NextRequest
|
||||||
): Promise<{ userId: string; email: string } | null> {
|
): Promise<{ userId: string; email: string } | null> {
|
||||||
const auth = getAuth(req);
|
const auth = getAuth(req);
|
||||||
if (!auth) return null;
|
if (!auth) return null;
|
||||||
const user = await User.findById(auth.sub).select("email isActive").lean();
|
const user = await User.findOne({ _id: auth.sub, businessId: auth.businessId })
|
||||||
|
.select("email isActive platformAdmin")
|
||||||
|
.lean();
|
||||||
if (!user || !user.isActive) return null;
|
if (!user || !user.isActive) return null;
|
||||||
if (!isPlatformAdminEmail(user.email)) return null;
|
if (!isPlatformAdmin(user)) return null;
|
||||||
return { userId: auth.sub, email: user.email };
|
return { userId: auth.sub, email: user.email };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Types } from "mongoose";
|
||||||
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two products with the same name (ignoring case) would be impossible to
|
||||||
|
* tell apart on the terminal and in reports, so names are unique per
|
||||||
|
* business — including deactivated products, which can be reactivated.
|
||||||
|
*/
|
||||||
|
export async function labelTakenError(
|
||||||
|
businessId: Types.ObjectId | string,
|
||||||
|
label: string,
|
||||||
|
exceptId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const filter: Record<string, unknown> = { businessId, label: label.trim() };
|
||||||
|
if (exceptId) filter._id = { $ne: exceptId };
|
||||||
|
const clash = await Product.findOne(filter).collation({ locale: "en", strength: 2 }).select("label isActive").lean();
|
||||||
|
if (!clash) return null;
|
||||||
|
return clash.isActive
|
||||||
|
? `A product called "${clash.label}" already exists.`
|
||||||
|
: `A deactivated product called "${clash.label}" already exists. Reactivate it instead.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import mongoose, { Types } from "mongoose";
|
||||||
|
import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
|
||||||
|
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { Product } from "@/lib/server/models/Product";
|
||||||
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
|
import { StockLock } from "@/lib/server/models/StockLock";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
|
import type { ITransaction } from "@/lib/server/models/Transaction";
|
||||||
|
|
||||||
|
type Id = Types.ObjectId | string;
|
||||||
|
|
||||||
|
export interface MovementInput {
|
||||||
|
businessId: Id;
|
||||||
|
branchId: Id;
|
||||||
|
productId: Id;
|
||||||
|
type: MovementType;
|
||||||
|
quantity: number; // signed
|
||||||
|
refId: string;
|
||||||
|
note?: string | null;
|
||||||
|
recordedBy?: Id | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write one ledger movement, idempotently: if a movement with the same
|
||||||
|
* (type, refId) already exists nothing changes. Safe to call again on any
|
||||||
|
* retry or replay.
|
||||||
|
*/
|
||||||
|
export async function recordMovement(m: MovementInput): Promise<void> {
|
||||||
|
await StockMovement.updateOne(
|
||||||
|
{ type: m.type, refId: m.refId, businessId: m.businessId },
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
businessId: m.businessId,
|
||||||
|
branchId: m.branchId,
|
||||||
|
productId: m.productId,
|
||||||
|
quantity: m.quantity,
|
||||||
|
note: m.note ?? null,
|
||||||
|
recordedBy: m.recordedBy ?? null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduct a completed sale from its branch's stock. Only SUCCESS sales move
|
||||||
|
* stock — a PENDING or FAILED M-Pesa prompt never does.
|
||||||
|
*/
|
||||||
|
export async function recordSaleMovement(
|
||||||
|
txn: Pick<ITransaction, "_id" | "businessId" | "branchId" | "productId" | "quantity" | "paymentStatus" | "staffId">
|
||||||
|
): Promise<void> {
|
||||||
|
if (txn.paymentStatus !== "SUCCESS") return;
|
||||||
|
await recordMovement({
|
||||||
|
businessId: txn.businessId,
|
||||||
|
branchId: txn.branchId,
|
||||||
|
productId: txn.productId,
|
||||||
|
type: "SALE",
|
||||||
|
quantity: -Math.abs(txn.quantity),
|
||||||
|
refId: txn._id.toString(),
|
||||||
|
recordedBy: txn.staffId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ledger refIds derived from client-generated keys are namespaced by tenant. */
|
||||||
|
export function clientRefId(businessId: Id, clientUuid: string): string {
|
||||||
|
return `${businessId.toString()}:${clientUuid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current stock of one product at one branch (the sum of its movements). */
|
||||||
|
export async function stockOf(businessId: Id, branchId: Id, productId: Id): Promise<number> {
|
||||||
|
const [row] = await StockMovement.aggregate<{ total: number }>([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
businessId: new mongoose.Types.ObjectId(businessId.toString()),
|
||||||
|
branchId: new mongoose.Types.ObjectId(branchId.toString()),
|
||||||
|
productId: new mongoose.Types.ObjectId(productId.toString()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ $group: { _id: null, total: { $sum: "$quantity" } } },
|
||||||
|
]);
|
||||||
|
return row?.total ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StockStatus = "OK" | "LOW" | "OUT";
|
||||||
|
|
||||||
|
export interface StockLevel {
|
||||||
|
branchId: string;
|
||||||
|
branchName: string;
|
||||||
|
productId: string;
|
||||||
|
productLabel: string;
|
||||||
|
unit: string;
|
||||||
|
quantity: number;
|
||||||
|
lowStockThreshold: number;
|
||||||
|
status: StockStatus;
|
||||||
|
productActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stockStatus(quantity: number, threshold: number): StockStatus {
|
||||||
|
if (quantity <= 0) return "OUT";
|
||||||
|
if (quantity <= threshold) return "LOW";
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stock of every active product at every active branch in scope —
|
||||||
|
* including zero rows, so an owner sees "0" rather than a missing product.
|
||||||
|
* A deactivated product is listed too wherever it still has stock, so
|
||||||
|
* units bought before deactivation never silently disappear. A
|
||||||
|
* negative quantity is reported as-is: it means sales were recorded
|
||||||
|
* (typically offline) for stock the ledger never received, and the branch
|
||||||
|
* needs a count/adjustment.
|
||||||
|
*/
|
||||||
|
export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise<StockLevel[]> {
|
||||||
|
const bizObj = new mongoose.Types.ObjectId(businessId.toString());
|
||||||
|
const branchFilter: Record<string, unknown> = { businessId: bizObj, ...ACTIVE_BRANCH };
|
||||||
|
if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString());
|
||||||
|
|
||||||
|
const [branches, products, totals] = await Promise.all([
|
||||||
|
Branch.find(branchFilter).sort({ branchName: 1 }).lean(),
|
||||||
|
Product.find({ businessId: bizObj }).sort({ sortOrder: 1 }).lean(),
|
||||||
|
StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([
|
||||||
|
{ $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } },
|
||||||
|
{ $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } },
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalByKey = new Map(totals.map((t) => [`${t._id.branchId}:${t._id.productId}`, t.total]));
|
||||||
|
const levels: StockLevel[] = [];
|
||||||
|
for (const b of branches) {
|
||||||
|
for (const p of products) {
|
||||||
|
const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0;
|
||||||
|
if (!p.isActive && quantity === 0) continue;
|
||||||
|
const lowStockThreshold = p.lowStockThreshold ?? 10;
|
||||||
|
levels.push({
|
||||||
|
branchId: b._id.toString(),
|
||||||
|
branchName: b.branchName,
|
||||||
|
productId: p._id.toString(),
|
||||||
|
productLabel: p.label,
|
||||||
|
unit: p.unit ?? "L",
|
||||||
|
quantity,
|
||||||
|
lowStockThreshold,
|
||||||
|
status: stockStatus(quantity, lowStockThreshold),
|
||||||
|
productActive: p.isActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the PURCHASE movement for every catalogue purchase that has none.
|
||||||
|
* Purchases recorded before the stock ledger existed (or while a write
|
||||||
|
* failed) otherwise never show up as stock. Idempotent: run it as often
|
||||||
|
* as you like. Returns how many movements were added.
|
||||||
|
*/
|
||||||
|
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
|
||||||
|
const filter: Record<string, unknown> = { productId: { $ne: null } };
|
||||||
|
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
|
||||||
|
const purchases = businessId
|
||||||
|
? await Purchase.find(filter).lean()
|
||||||
|
: await crossTenant("the backfill runs over every business", () => Purchase.find(filter).lean().exec());
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
for (const p of purchases) {
|
||||||
|
const refId = p._id.toString();
|
||||||
|
if (await StockMovement.exists({ type: "PURCHASE", refId, businessId: p.businessId })) continue;
|
||||||
|
await recordMovement({
|
||||||
|
businessId: p.businessId,
|
||||||
|
branchId: p.branchId,
|
||||||
|
productId: p.productId!,
|
||||||
|
type: "PURCHASE",
|
||||||
|
quantity: p.quantity,
|
||||||
|
refId,
|
||||||
|
note: `From ${p.supplierName} (backfilled)`,
|
||||||
|
recordedBy: p.recordedBy,
|
||||||
|
});
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCK_LEASE_MS = 10_000;
|
||||||
|
const LOCK_WAIT_MS = 5_000;
|
||||||
|
|
||||||
|
/** Another request kept the stock busy for longer than we are willing to wait. */
|
||||||
|
export class StockBusyError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("This stock is being updated by someone else. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` while holding the lock on one product's stock at one branch.
|
||||||
|
*
|
||||||
|
* Anything that reads the balance and then writes a movement based on it
|
||||||
|
* (a transfer refusing to overdraw, a physical count computing its
|
||||||
|
* correction) must run inside this. Without it, two transfers that each
|
||||||
|
* see 10 in stock can both send 6, because the read and the write are
|
||||||
|
* separate operations. With it, the second waits for the first and then
|
||||||
|
* sees 4.
|
||||||
|
*
|
||||||
|
* The lock is a lease row: acquired with one atomic upsert that only
|
||||||
|
* succeeds if no unexpired lease exists (otherwise the insert hits the
|
||||||
|
* unique _id and fails), released by deleting it. Sales do not take the
|
||||||
|
* lock - they are recorded even when stock runs out (see PILOT_READINESS).
|
||||||
|
*/
|
||||||
|
export async function withStockLock<T>(businessId: Id, branchId: Id, productId: Id, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const key = `${businessId}:${branchId}:${productId}`;
|
||||||
|
const owner = crypto.randomUUID();
|
||||||
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
||||||
|
let delay = 10;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const now = Date.now();
|
||||||
|
try {
|
||||||
|
await StockLock.updateOne(
|
||||||
|
{ _id: key, lockedUntil: { $lt: new Date(now) } },
|
||||||
|
{ $set: { owner, lockedUntil: new Date(now + LOCK_LEASE_MS) } },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as { code?: number })?.code !== 11000) throw err;
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) throw new StockBusyError();
|
||||||
|
await new Promise((r) => setTimeout(r, delay + Math.random() * delay));
|
||||||
|
delay = Math.min(delay * 2, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
await StockLock.deleteOne({ _id: key, owner });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,25 +8,27 @@ import { Types } from "mongoose";
|
|||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { User } from "@/lib/server/models/User";
|
import { User } from "@/lib/server/models/User";
|
||||||
import { Business, IBusiness } from "@/lib/server/models/Business";
|
import { Business, IBusiness } from "@/lib/server/models/Business";
|
||||||
import { TIERS, type TierId } from "@/lib/tiers";
|
import { TIERS, effectiveMaxBranches, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||||
|
|
||||||
export function tierOf(business: Pick<IBusiness, "tier">): TierId {
|
export function tierOf(business: Pick<IBusiness, "tier">): TierId {
|
||||||
return (business.tier as TierId) ?? "STARTER";
|
return (business.tier as TierId) ?? "STARTER";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an error message string if adding one more branch would exceed
|
* Returns an error message string if adding (or reactivating) one more
|
||||||
* the business's plan, or null if it's allowed.
|
* branch would exceed the business's plan, or null if it's allowed. Only
|
||||||
|
* active branches count; trials get TRIAL_MAX_BRANCHES.
|
||||||
*/
|
*/
|
||||||
export async function branchLimitError(
|
export async function branchLimitError(businessId: Types.ObjectId | string): Promise<string | null> {
|
||||||
businessId: Types.ObjectId | string,
|
const biz = await Business.findById(businessId).select("tier subscriptionStatus").lean();
|
||||||
tier: TierId
|
const tier = (biz?.tier as TierId) ?? "STARTER";
|
||||||
): Promise<string | null> {
|
const status = (biz?.subscriptionStatus as SubscriptionStatus) ?? "TRIALING";
|
||||||
const max = TIERS[tier].maxBranches;
|
const max = effectiveMaxBranches(tier, status);
|
||||||
if (max === null) return null; // unlimited
|
if (max === null) return null; // unlimited
|
||||||
const count = await Branch.countDocuments({ businessId });
|
const count = await Branch.countDocuments({ businessId, isActive: { $ne: false } });
|
||||||
if (count >= max) {
|
if (count >= max) {
|
||||||
return `Your ${TIERS[tier].name} plan allows ${max} branch${max === 1 ? "" : "es"}. Upgrade to add more.`;
|
const plan = status === "TRIALING" ? "free trial" : `${TIERS[tier].name} plan`;
|
||||||
|
return `Your ${plan} allows ${max} active branch${max === 1 ? "" : "es"}. Upgrade to add more.`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -52,4 +54,19 @@ export async function userLimitError(
|
|||||||
export async function loadTier(businessId: Types.ObjectId | string): Promise<TierId> {
|
export async function loadTier(businessId: Types.ObjectId | string): Promise<TierId> {
|
||||||
const biz = await Business.findById(businessId).select("tier").lean();
|
const biz = await Business.findById(businessId).select("tier").lean();
|
||||||
return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness);
|
return tierOf({ tier: (biz?.tier as TierId) ?? "STARTER" } as IBusiness);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a user-facing reason this tenant may not use the app right now,
|
||||||
|
* or null when access is allowed. Checked at login and on every token
|
||||||
|
* refresh, so a suspension takes effect within one access-token lifetime
|
||||||
|
* (15 minutes) even for devices that are already signed in.
|
||||||
|
*/
|
||||||
|
export async function tenantAccessError(businessId: Types.ObjectId | string): Promise<string | null> {
|
||||||
|
const biz = await Business.findById(businessId).select("isActive subscriptionStatus").lean();
|
||||||
|
if (!biz || !biz.isActive) return "This business account has been deactivated.";
|
||||||
|
if (!isUsable((biz.subscriptionStatus as SubscriptionStatus) ?? "TRIALING")) {
|
||||||
|
return "This business account is suspended. Contact support to restore access.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { AsyncLocalStorage } from "async_hooks";
|
||||||
|
import { Types, type MongooseQueryMiddleware, type Schema } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant isolation, enforced instead of remembered.
|
||||||
|
*
|
||||||
|
* Every model that holds one business's data is guarded: a query against
|
||||||
|
* it that does not name exactly one business (`businessId: <id>`) throws
|
||||||
|
* TenantScopeError before it reaches the database. A route that forgets
|
||||||
|
* the business filter therefore fails loudly in development and in the
|
||||||
|
* test suite, instead of quietly reading or changing another business's
|
||||||
|
* data in production.
|
||||||
|
*
|
||||||
|
* The few queries that legitimately span businesses (login looking an
|
||||||
|
* email up everywhere, an invite token, the payment provider's callback,
|
||||||
|
* the platform console, maintenance scripts) must say so explicitly:
|
||||||
|
*
|
||||||
|
* await crossTenant("login looks the email up in every business", () => User.find(...));
|
||||||
|
*
|
||||||
|
* Guarded: find, findOne (and findById / exists), findOneAndUpdate /
|
||||||
|
* Delete / Replace, updateOne / updateMany, replaceOne, deleteOne /
|
||||||
|
* deleteMany, countDocuments, distinct, and aggregate (its first stage
|
||||||
|
* must be a $match naming the business). populate() runs a find on the
|
||||||
|
* referenced model, so populate through `scopedPopulate` below.
|
||||||
|
*
|
||||||
|
* Not guarded, by design: Business (the tenant itself, always looked up by
|
||||||
|
* the id from the session), UserSession (keyed by the signed-in user),
|
||||||
|
* MpesaCallbackLog (provider audit log), StockLock (key already contains
|
||||||
|
* the business). Document saves and inserts are covered by `businessId`
|
||||||
|
* being a required field.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class TenantScopeError extends Error {
|
||||||
|
constructor(model: string, op: string) {
|
||||||
|
super(
|
||||||
|
`Tenant isolation: ${model}.${op} must filter by a single businessId ` +
|
||||||
|
`(or run inside crossTenant("why", ...) if it really spans businesses).`
|
||||||
|
);
|
||||||
|
this.name = "TenantScopeError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossTenantReason = new AsyncLocalStorage<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn`, whose queries deliberately span businesses. `reason` documents
|
||||||
|
* why. The result is awaited inside the scope, so a lazy Mongoose query
|
||||||
|
* returned by `fn` executes while the exemption applies.
|
||||||
|
*/
|
||||||
|
export function crossTenant<T>(reason: string, fn: () => PromiseLike<T>): Promise<T> {
|
||||||
|
if (!reason.trim()) throw new Error("crossTenant needs a reason");
|
||||||
|
return crossTenantReason.run(reason, async () => await fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the filter pins exactly one business. */
|
||||||
|
export function namesOneBusiness(filter: unknown): boolean {
|
||||||
|
if (!filter || typeof filter !== "object") return false;
|
||||||
|
const f = filter as Record<string, unknown>;
|
||||||
|
const v = f.businessId;
|
||||||
|
if (typeof v === "string" || v instanceof Types.ObjectId) return true;
|
||||||
|
if (v && typeof v === "object" && "$eq" in (v as object)) {
|
||||||
|
const eq = (v as { $eq: unknown }).$eq;
|
||||||
|
return typeof eq === "string" || eq instanceof Types.ObjectId;
|
||||||
|
}
|
||||||
|
return Array.isArray(f.$and) && f.$and.some(namesOneBusiness);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_OPS: MongooseQueryMiddleware[] = [
|
||||||
|
"find",
|
||||||
|
"findOne",
|
||||||
|
"findOneAndUpdate",
|
||||||
|
"findOneAndDelete",
|
||||||
|
"findOneAndReplace",
|
||||||
|
"updateOne",
|
||||||
|
"updateMany",
|
||||||
|
"replaceOne",
|
||||||
|
"deleteOne",
|
||||||
|
"deleteMany",
|
||||||
|
"countDocuments",
|
||||||
|
"distinct",
|
||||||
|
];
|
||||||
|
|
||||||
|
const guardedSchemas = new WeakSet<Schema>();
|
||||||
|
|
||||||
|
/** Whether a schema has the guard (the test suite checks every business-scoped model does). */
|
||||||
|
export function isTenantGuarded(schema: Schema): boolean {
|
||||||
|
return guardedSchemas.has(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mongoose plugin: apply to every schema with a `businessId` path. */
|
||||||
|
export function tenantGuard(schema: Schema): void {
|
||||||
|
guardedSchemas.add(schema);
|
||||||
|
|
||||||
|
schema.pre(QUERY_OPS, function (this: any) {
|
||||||
|
if (crossTenantReason.getStore()) return;
|
||||||
|
if (!namesOneBusiness(this.getFilter())) throw new TenantScopeError(this.model.modelName, this.op);
|
||||||
|
});
|
||||||
|
|
||||||
|
schema.pre("aggregate", function (this: any) {
|
||||||
|
if (crossTenantReason.getStore()) return;
|
||||||
|
const first = this.pipeline()[0];
|
||||||
|
if (!first || !namesOneBusiness(first.$match)) {
|
||||||
|
throw new TenantScopeError(this._model?.modelName ?? "Model", "aggregate");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* populate() options that keep the referenced documents inside the
|
||||||
|
* business too (populate queries the referenced model, which is guarded).
|
||||||
|
*/
|
||||||
|
export function scopedPopulate(path: string, select: string, businessId: string | Types.ObjectId) {
|
||||||
|
return { path, select, match: { businessId } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
|
|
||||||
|
/** A MongoDB ObjectId string. Validating up front turns a would-be CastError 500 into a 400. */
|
||||||
|
export const objectId = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((v) => isValidObjectId(v) && /^[0-9a-f]{24}$/i.test(v), { message: "Invalid id" });
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { AuthUser, Product } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last confirmed session and catalogue, so the terminal can start and
|
||||||
|
* keep selling with no network. Only a snapshot for display and for
|
||||||
|
* labelling queued sales: the server still authenticates every sync with
|
||||||
|
* the httpOnly cookies and re-prices every line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KEY = "aquaflow_terminal_session";
|
||||||
|
|
||||||
|
/** The branch's stock as the server last reported it. */
|
||||||
|
export interface StockSnapshot {
|
||||||
|
byProduct: Record<string, number>;
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSnapshot {
|
||||||
|
user: AuthUser;
|
||||||
|
features: { mpesa: boolean };
|
||||||
|
products: Product[];
|
||||||
|
stock?: StockSnapshot;
|
||||||
|
savedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSession(): SessionSnapshot | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as SessionSnapshot) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(update: Partial<SessionSnapshot> & { user: AuthUser }): void {
|
||||||
|
try {
|
||||||
|
const prev = loadSession();
|
||||||
|
const sameUser = prev?.user.id === update.user.id;
|
||||||
|
const next: SessionSnapshot = {
|
||||||
|
features: update.features ?? (sameUser ? prev!.features : { mpesa: false }),
|
||||||
|
products: update.products ?? (sameUser ? prev!.products : []),
|
||||||
|
stock: update.stock ?? (sameUser ? prev!.stock : undefined),
|
||||||
|
user: update.user,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// Storage full or blocked: the terminal still works online.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
import { clearSession } from "@/lib/sessionCache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End this device's session and forget the cached terminal session. Works
|
||||||
|
* offline too: the server session then expires on its own, and this device
|
||||||
|
* keeps nothing that would sign it back in.
|
||||||
|
*/
|
||||||
|
export async function signOut(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||||
|
} catch {
|
||||||
|
// Offline or already signed out.
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// Shared between the terminal's offline queue and POST /api/transactions/cash.
|
||||||
|
|
||||||
|
/** Largest batch the offline queue sends in one request. */
|
||||||
|
export const MAX_SALES_PER_REQUEST = 50;
|
||||||
|
|
||||||
|
/** Largest number of product lines in one sale. */
|
||||||
|
export const MAX_LINES_PER_SALE = 20;
|
||||||
|
|
||||||
|
export type SaleResult =
|
||||||
|
| { clientUuid: string; status: "saved" | "duplicate"; amountTotal: number; priceMismatch: boolean }
|
||||||
|
| { clientUuid: string; status: "rejected"; error: string };
|
||||||
@@ -13,6 +13,7 @@ export interface TierDef {
|
|||||||
priceKsh: number; // monthly price in KSh
|
priceKsh: number; // monthly price in KSh
|
||||||
maxBranches: number | null; // null = unlimited
|
maxBranches: number | null; // null = unlimited
|
||||||
maxUsers: number | null; // total users in the business (owner + managers + staff); null = unlimited
|
maxUsers: number | null; // total users in the business (owner + managers + staff); null = unlimited
|
||||||
|
branchPricing: boolean; // may set a different price per branch
|
||||||
features: string[];
|
features: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export const TIERS: Record<TierId, TierDef> = {
|
|||||||
priceKsh: 3500,
|
priceKsh: 3500,
|
||||||
maxBranches: 1,
|
maxBranches: 1,
|
||||||
maxUsers: 6, // owner + up to 5 staff/manager
|
maxUsers: 6, // owner + up to 5 staff/manager
|
||||||
|
branchPricing: false,
|
||||||
features: [
|
features: [
|
||||||
"1 branch",
|
"1 branch",
|
||||||
"Up to 5 staff accounts",
|
"Up to 5 staff accounts",
|
||||||
@@ -37,6 +39,7 @@ export const TIERS: Record<TierId, TierDef> = {
|
|||||||
priceKsh: 7500,
|
priceKsh: 7500,
|
||||||
maxBranches: 5,
|
maxBranches: 5,
|
||||||
maxUsers: 30,
|
maxUsers: 30,
|
||||||
|
branchPricing: true,
|
||||||
features: [
|
features: [
|
||||||
"Up to 5 branches",
|
"Up to 5 branches",
|
||||||
"Up to 30 staff accounts",
|
"Up to 30 staff accounts",
|
||||||
@@ -51,6 +54,7 @@ export const TIERS: Record<TierId, TierDef> = {
|
|||||||
priceKsh: 15000,
|
priceKsh: 15000,
|
||||||
maxBranches: null,
|
maxBranches: null,
|
||||||
maxUsers: null,
|
maxUsers: null,
|
||||||
|
branchPricing: true,
|
||||||
features: [
|
features: [
|
||||||
"Unlimited branches",
|
"Unlimited branches",
|
||||||
"Unlimited staff accounts",
|
"Unlimited staff accounts",
|
||||||
@@ -66,6 +70,28 @@ export const DEFAULT_TIER: TierId = "STARTER";
|
|||||||
// Days of free trial a brand-new business gets on signup.
|
// Days of free trial a brand-new business gets on signup.
|
||||||
export const TRIAL_DAYS = 14;
|
export const TRIAL_DAYS = 14;
|
||||||
|
|
||||||
|
// Branches allowed while a business is TRIALING, whatever its tier, so a
|
||||||
|
// new owner can try the multi-branch features (transfers, per-branch
|
||||||
|
// stock and reports) before paying. After the trial the tier's own limit
|
||||||
|
// applies to adding branches; existing branches are never removed.
|
||||||
|
export const TRIAL_MAX_BRANCHES = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the business may set branch-specific prices right now: a plan
|
||||||
|
* feature (Growth and up), also available during the free trial so it can
|
||||||
|
* be tried with the trial's two branches.
|
||||||
|
*/
|
||||||
|
export function canUseBranchPricing(tier: TierId, status: SubscriptionStatus): boolean {
|
||||||
|
return TIERS[tier].branchPricing || status === "TRIALING";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Branch limit for a business right now (null = unlimited). */
|
||||||
|
export function effectiveMaxBranches(tier: TierId, status: SubscriptionStatus): number | null {
|
||||||
|
const max = TIERS[tier].maxBranches;
|
||||||
|
if (status !== "TRIALING" || max === null) return max;
|
||||||
|
return Math.max(max, TRIAL_MAX_BRANCHES);
|
||||||
|
}
|
||||||
|
|
||||||
export type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
export type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
||||||
|
|
||||||
export function formatKsh(amount: number): string {
|
export function formatKsh(amount: number): string {
|
||||||
|
|||||||
+26
-2
@@ -7,6 +7,7 @@ export interface Branch {
|
|||||||
branchName: string;
|
branchName: string;
|
||||||
locationCity: string;
|
locationCity: string;
|
||||||
managerId: string | null;
|
managerId: string | null;
|
||||||
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
@@ -31,6 +32,7 @@ export interface Product {
|
|||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
lowStockThreshold?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
@@ -38,7 +40,7 @@ export interface Transaction {
|
|||||||
branchId: string | { _id: string; branchName: string };
|
branchId: string | { _id: string; branchName: string };
|
||||||
staffId: string;
|
staffId: string;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
customerPhone: string;
|
customerPhone: string | null;
|
||||||
sizeLiters: number;
|
sizeLiters: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
@@ -83,4 +85,26 @@ export interface ReconciliationRow {
|
|||||||
totalSold: number;
|
totalSold: number;
|
||||||
expectedRemaining: number;
|
expectedRemaining: number;
|
||||||
reconciledOk: boolean | null;
|
reconciledOk: boolean | null;
|
||||||
}
|
}
|
||||||
|
export interface StockLevel {
|
||||||
|
branchId: string;
|
||||||
|
branchName: string;
|
||||||
|
productId: string;
|
||||||
|
productLabel: string;
|
||||||
|
unit: string;
|
||||||
|
quantity: number;
|
||||||
|
lowStockThreshold: number;
|
||||||
|
status: "OK" | "LOW" | "OUT";
|
||||||
|
productActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockMovementRow {
|
||||||
|
_id: string;
|
||||||
|
type: "SALE" | "PURCHASE" | "TRANSFER_OUT" | "TRANSFER_IN" | "ADJUSTMENT";
|
||||||
|
quantity: number;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
branchId: { _id: string; branchName: string } | null;
|
||||||
|
productId: { _id: string; label: string; unit?: string } | null;
|
||||||
|
recordedBy: { _id: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Units a product can be measured in. Water refills are sized in litres;
|
||||||
|
// side items (eggs, bottles, bags) are counted.
|
||||||
|
export const PRODUCT_UNITS = ["L", "ml", "kg", "g", "pcs", "tray", "pack"] as const;
|
||||||
|
export type ProductUnit = (typeof PRODUCT_UNITS)[number];
|
||||||
|
|
||||||
|
export const UNIT_LABELS: Record<ProductUnit, string> = {
|
||||||
|
L: "litres",
|
||||||
|
ml: "millilitres",
|
||||||
|
kg: "kilograms",
|
||||||
|
g: "grams",
|
||||||
|
pcs: "pieces",
|
||||||
|
tray: "trays",
|
||||||
|
pack: "packs",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "20L", "500ml", "30 pcs", "1 tray". */
|
||||||
|
export function formatSize(size: number, unit: string | undefined): string {
|
||||||
|
const u = unit || "L";
|
||||||
|
return u === "L" || u === "ml" || u === "kg" || u === "g" ? `${size}${u}` : `${size} ${u}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
|
import * as profit from "@/app/api/reports/profit/route";
|
||||||
|
import * as expenses from "@/app/api/expenses/route";
|
||||||
|
import * as purchases from "@/app/api/purchases/route";
|
||||||
|
import * as products from "@/app/api/products/route";
|
||||||
|
import * as productById from "@/app/api/products/[id]/route";
|
||||||
|
import * as userById from "@/app/api/users/[id]/route";
|
||||||
|
import * as branchById from "@/app/api/branches/[id]/route";
|
||||||
|
import * as summary from "@/app/api/dashboard/summary/route";
|
||||||
|
import * as login from "@/app/api/auth/login/route";
|
||||||
|
import * as signup from "@/app/api/auth/signup/route";
|
||||||
|
import * as stkpush from "@/app/api/mpesa/stkpush/route";
|
||||||
|
import * as callback from "@/app/api/mpesa/callback/route";
|
||||||
|
import { json, makeTenant, params, req, uuid, db } from "./helpers";
|
||||||
|
|
||||||
|
describe("role-based access on the backend", () => {
|
||||||
|
it("blocks cashiers from owner-level money endpoints with 403", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const as = t.cashierA;
|
||||||
|
const responses = await Promise.all([
|
||||||
|
profit.GET(req("GET", "/api/reports/profit", { as })),
|
||||||
|
expenses.GET(req("GET", "/api/expenses", { as })),
|
||||||
|
purchases.GET(req("GET", "/api/purchases", { as })),
|
||||||
|
expenses.POST(req("POST", "/api/expenses", { as, body: { category: "Rent", amount: 10, description: "x" } })),
|
||||||
|
purchases.POST(
|
||||||
|
req("POST", "/api/purchases", { as, body: { supplierName: "s", productLabel: "x", quantity: 1, unitCost: 1 } })
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
expect(responses.map((r) => r.status)).toEqual([403, 403, 403, 403, 403]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still lets a branch manager use them for their own branch", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const res = await expenses.POST(
|
||||||
|
req("POST", "/api/expenses", { as: t.managerA, body: { category: "Rent", amount: 10, description: "rent" } })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect((await profit.GET(req("GET", "/api/reports/profit", { as: t.managerA }))).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401, not 403, when signed out", async () => {
|
||||||
|
expect((await profit.GET(req("GET", "/api/reports/profit"))).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only lets the owner create or reprice products", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const body = { label: "1L", sizeLiters: 1, unitPrice: 20 };
|
||||||
|
expect((await products.POST(req("POST", "/api/products", { as: t.managerA, body }))).status).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await productById.PATCH(req("PATCH", "/x", { as: t.managerA, body: { unitPrice: 1 } }), params({ id: t.water20 })))
|
||||||
|
.status
|
||||||
|
).toBe(403);
|
||||||
|
expect((await products.POST(req("POST", "/api/products", { as: t.owner, body }))).status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed branchId with 400 instead of crashing", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const res = await summary.GET(req("GET", "/api/dashboard/summary?branchId=abc", { as: t.owner }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("user deactivation", () => {
|
||||||
|
it("lets a manager deactivate their own cashier and revokes that cashier's sessions", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await UserSession.create({
|
||||||
|
userId: t.cashierA.id,
|
||||||
|
deviceId: uuid(),
|
||||||
|
refreshTokenHash: "h",
|
||||||
|
expiresAt: new Date(Date.now() + 86_400_000),
|
||||||
|
});
|
||||||
|
const res = await userById.PATCH(
|
||||||
|
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
|
||||||
|
params({ id: t.cashierA.id })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await db(() => User.findById(t.cashierA.id)))!.isActive).toBe(false);
|
||||||
|
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a manager touch another branch's staff", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const res = await userById.PATCH(
|
||||||
|
req("PATCH", "/x", { as: t.managerA, body: { isActive: false } }),
|
||||||
|
params({ id: t.cashierB.id })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let the owner deactivate themselves", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const res = await userById.PATCH(
|
||||||
|
req("PATCH", "/x", { as: t.owner, body: { isActive: false } }),
|
||||||
|
params({ id: t.owner.id })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tenant status", () => {
|
||||||
|
it("refuses login for a suspended business", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||||
|
await db(() => User.updateOne({ _id: t.cashierA.id }, { passwordHash }));
|
||||||
|
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
|
||||||
|
|
||||||
|
const res = await login.POST(
|
||||||
|
req("POST", "/api/auth/login", {
|
||||||
|
body: { email: t.cashierA.email, password: "correct-horse", deviceId: uuid() },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still allows login for an active business", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||||
|
await db(() => User.updateOne({ _id: t.managerA.id }, { passwordHash }));
|
||||||
|
const res = await login.POST(
|
||||||
|
req("POST", "/api/auth/login", {
|
||||||
|
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("platform and M-Pesa hardening", () => {
|
||||||
|
it("refuses public signup with a platform-operator email", async () => {
|
||||||
|
const res = await signup.POST(
|
||||||
|
req("POST", "/api/auth/signup", {
|
||||||
|
body: {
|
||||||
|
businessName: "Takeover Ltd",
|
||||||
|
ownerName: "Mallory",
|
||||||
|
email: "operator@aquaflow.test",
|
||||||
|
phoneNumber: "0712345678",
|
||||||
|
password: "password123",
|
||||||
|
deviceId: uuid(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides every M-Pesa route when FEATURE_MPESA is off", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const push = await stkpush.POST(
|
||||||
|
req("POST", "/api/mpesa/stkpush", { as: t.cashierA, body: { customerPhone: "0712345678", productId: t.water20, quantity: 1 } })
|
||||||
|
);
|
||||||
|
const cb = await callback.POST(req("POST", "/api/mpesa/callback", { body: { Body: {} } }));
|
||||||
|
expect([push.status, cb.status]).toEqual([404, 404]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets only the owner rename a branch", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const body = { branchName: "Renamed" };
|
||||||
|
expect((await branchById.PATCH(req("PATCH", "/x", { as: t.managerA, body }), params({ id: t.branchA }))).status).toBe(403);
|
||||||
|
const ok = await json(await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body }), params({ id: t.branchA })));
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect(ok.body.branch.branchName).toBe("Renamed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
|
import * as invite from "@/app/api/users/[id]/invite/route";
|
||||||
|
import * as invites from "@/app/api/invites/[token]/route";
|
||||||
|
import * as users from "@/app/api/users/route";
|
||||||
|
import * as userById from "@/app/api/users/[id]/route";
|
||||||
|
import * as password from "@/app/api/auth/password/route";
|
||||||
|
import * as login from "@/app/api/auth/login/route";
|
||||||
|
import * as ownerReset from "@/app/api/platform/businesses/[id]/owner-reset/route";
|
||||||
|
import { db, json, makeTenant, params, req, uuid, type Actor } from "./helpers";
|
||||||
|
|
||||||
|
const DEVICE = "00000000-0000-4000-8000-000000000001"; // the device tokenFor() signs in on
|
||||||
|
|
||||||
|
async function setPassword(a: Actor, pw: string) {
|
||||||
|
await db(() => User.updateOne({ _id: a.id }, { passwordHash: bcrypt.hashSync(pw, 4) }));
|
||||||
|
}
|
||||||
|
const signIn = (a: Actor, pw: string) =>
|
||||||
|
login.POST(req("POST", "/x", { body: { email: a.email, password: pw, deviceId: uuid() } }));
|
||||||
|
const tokenOf = (url: string) => url.split("/invite/")[1];
|
||||||
|
const sessions = (a: Actor) => db(() => UserSession.countDocuments({ userId: a.id }));
|
||||||
|
|
||||||
|
describe("password reset links", () => {
|
||||||
|
it("lets a manager reset their cashier's forgotten password; the old one works until the link is used", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await setPassword(t.cashierA, "old-password");
|
||||||
|
await signIn(t.cashierA, "old-password"); // a session on some phone
|
||||||
|
|
||||||
|
const r = await json(await invite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierA.id })));
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.purpose).toBe("reset");
|
||||||
|
expect((await signIn(t.cashierA, "old-password")).status).toBe(200);
|
||||||
|
|
||||||
|
const page = await json(await invites.GET(req("GET", "/x"), params({ token: tokenOf(r.body.inviteUrl) })));
|
||||||
|
expect(page.body.purpose).toBe("reset");
|
||||||
|
|
||||||
|
const accept = await invites.POST(req("POST", "/x", { body: { password: "new-password" } }), params({ token: tokenOf(r.body.inviteUrl) }));
|
||||||
|
expect(accept.status).toBe(200);
|
||||||
|
expect(await sessions(t.cashierA)).toBe(0); // every device signed out
|
||||||
|
expect((await signIn(t.cashierA, "old-password")).status).toBe(401);
|
||||||
|
expect((await signIn(t.cashierA, "new-password")).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show a reset as a pending invite", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await invite.POST(req("POST", "/x", { as: t.owner }), params({ id: t.cashierA.id }));
|
||||||
|
const list = await json(await users.GET(req("GET", "/api/users", { as: t.owner })));
|
||||||
|
expect(list.body.users.find((u: any) => u._id === t.cashierA.id).invitePending).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires a reset link after 24 hours", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const r = await json(await invite.POST(req("POST", "/x", { as: t.owner }), params({ id: t.cashierA.id })));
|
||||||
|
const user = await db(() => User.findById(t.cashierA.id).lean());
|
||||||
|
const hours = (user!.inviteExpiresAt!.getTime() - Date.now()) / 3_600_000;
|
||||||
|
expect(hours).toBeGreaterThan(23.9);
|
||||||
|
expect(hours).toBeLessThanOrEqual(24);
|
||||||
|
await db(() => User.updateOne({ _id: t.cashierA.id }, { inviteExpiresAt: new Date(Date.now() - 1000) }));
|
||||||
|
expect((await invites.GET(req("GET", "/x"), params({ token: tokenOf(r.body.inviteUrl) }))).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps managers to their own cashiers, and nobody resets themselves", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const post = (as: Actor, id: string) => invite.POST(req("POST", "/x", { as }), params({ id }));
|
||||||
|
expect((await post(t.managerA, t.cashierB.id)).status).toBe(403);
|
||||||
|
expect((await post(t.managerA, t.owner.id)).status).toBe(403);
|
||||||
|
expect((await post(t.owner, t.owner.id)).status).toBe(409);
|
||||||
|
expect((await post(t.cashierA, t.cashierB.id)).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the platform operator give a business owner a reset link, and nobody else", async () => {
|
||||||
|
const t = await makeTenant("Customer Co");
|
||||||
|
const home = await makeTenant("Operator Co");
|
||||||
|
await db(() => User.updateOne({ _id: home.owner.id }, { email: "operator@aquaflow.test", platformAdmin: true }));
|
||||||
|
const operator = { ...home.owner, email: "operator@aquaflow.test" };
|
||||||
|
|
||||||
|
const ok = await json(await ownerReset.POST(req("POST", "/x", { as: operator }), params({ id: t.businessId })));
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect(ok.body).toMatchObject({ purpose: "reset", email: t.owner.email.toLowerCase() });
|
||||||
|
|
||||||
|
expect((await ownerReset.POST(req("POST", "/x", { as: t.owner }), params({ id: t.businessId }))).status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("changing your own password", () => {
|
||||||
|
it("needs the current password, then signs out every other device but this one", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await setPassword(t.managerA, "current-pass");
|
||||||
|
await signIn(t.managerA, "current-pass"); // another device
|
||||||
|
await db(() => UserSession.create({ userId: t.managerA.id, deviceId: DEVICE, refreshTokenHash: "x", expiresAt: new Date(Date.now() + 86_400_000) }));
|
||||||
|
expect(await sessions(t.managerA)).toBe(2);
|
||||||
|
|
||||||
|
const change = (currentPassword: string, newPassword: string) =>
|
||||||
|
password.POST(req("POST", "/x", { as: t.managerA, body: { currentPassword, newPassword } }));
|
||||||
|
expect((await change("wrong-pass", "brand-new-pass")).status).toBe(400);
|
||||||
|
expect((await change("current-pass", "short")).status).toBe(400);
|
||||||
|
expect((await change("current-pass", "brand-new-pass")).status).toBe(200);
|
||||||
|
|
||||||
|
expect(await sessions(t.managerA)).toBe(1);
|
||||||
|
expect(await db(() => UserSession.exists({ userId: t.managerA.id, deviceId: DEVICE }))).toBeTruthy();
|
||||||
|
expect((await signIn(t.managerA, "brand-new-pass")).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moving staff between branches", () => {
|
||||||
|
it("moves a manager, updates both branches' manager, and signs them out", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await db(() => Branch.updateOne({ _id: t.branchA }, { managerId: t.managerA.id }));
|
||||||
|
await db(() => UserSession.create({ userId: t.managerA.id, deviceId: uuid(), refreshTokenHash: "x", expiresAt: new Date(Date.now() + 86_400_000) }));
|
||||||
|
|
||||||
|
const res = await userById.PATCH(req("PATCH", "/x", { as: t.owner, body: { branchId: t.branchB } }), params({ id: t.managerA.id }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const [a, b, mgr] = await Promise.all([
|
||||||
|
db(() => Branch.findById(t.branchA).lean()),
|
||||||
|
db(() => Branch.findById(t.branchB).lean()),
|
||||||
|
db(() => User.findById(t.managerA.id).lean()),
|
||||||
|
]);
|
||||||
|
expect(a!.managerId).toBeNull();
|
||||||
|
expect(b!.managerId!.toString()).toBe(t.managerA.id);
|
||||||
|
expect(mgr!.branchId!.toString()).toBe(t.branchB);
|
||||||
|
expect(await sessions(t.managerA)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only lets the owner move people, and only to an active branch", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const move = (as: Actor, branchId: string) =>
|
||||||
|
userById.PATCH(req("PATCH", "/x", { as, body: { branchId } }), params({ id: t.cashierA.id }));
|
||||||
|
expect((await move(t.managerA, t.branchB)).status).toBe(403);
|
||||||
|
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
|
||||||
|
expect((await move(t.owner, t.branchB)).status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("where sign-in lands", () => {
|
||||||
|
it("sends the platform operator to the platform console, owners to the dashboard, cashiers to the till", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await Promise.all([setPassword(t.owner, "owner-pass"), setPassword(t.cashierA, "cashier-pass")]);
|
||||||
|
expect((await json(await signIn(t.owner, "owner-pass"))).body.redirectTo).toBe("/admin/dashboard");
|
||||||
|
expect((await json(await signIn(t.cashierA, "cashier-pass"))).body.redirectTo).toBe("/terminal");
|
||||||
|
|
||||||
|
await db(() => User.updateOne({ _id: t.owner.id }, { email: "operator@aquaflow.test", platformAdmin: true }));
|
||||||
|
const operator = { ...t.owner, email: "operator@aquaflow.test" };
|
||||||
|
expect((await json(await signIn(operator, "owner-pass"))).body.redirectTo).toBe("/platform");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { BranchPricing } from "@/lib/server/models/Product";
|
||||||
|
import * as prices from "@/app/api/branch-prices/route";
|
||||||
|
import * as products from "@/app/api/products/route";
|
||||||
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
|
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
|
const setPrice = (as: Actor, branchId: string, productId: string, unitPrice: number) =>
|
||||||
|
prices.PUT(req("PUT", "/api/branch-prices", { as, body: { branchId, productId, unitPrice } }));
|
||||||
|
|
||||||
|
const tillPrice = async (as: Actor, productId: string) => {
|
||||||
|
const { body } = await json(await products.GET(req("GET", "/api/products", { as })));
|
||||||
|
return body.products.find((p: any) => p._id === productId).unitPrice;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("branch prices", () => {
|
||||||
|
it("charges the branch's own price at that branch only, through the till and the sale", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
expect((await setPrice(t.owner, t.branchA, t.water20, 180)).status).toBe(200);
|
||||||
|
|
||||||
|
expect(await tillPrice(t.cashierA, t.water20)).toBe(180);
|
||||||
|
expect(await tillPrice(t.cashierB, t.water20)).toBe(200);
|
||||||
|
|
||||||
|
const sale = await json(
|
||||||
|
await cash.POST(req("POST", "/x", { as: t.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: t.water20, quantity: 2 }] }] } }))
|
||||||
|
);
|
||||||
|
expect(sale.body.results[0]).toMatchObject({ status: "saved", amountTotal: 360 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists default and branch prices, and the catalogue view keeps showing defaults", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await setPrice(t.owner, t.branchA, t.water20, 180);
|
||||||
|
const { body } = await json(await prices.GET(req("GET", "/api/branch-prices?branchId=" + t.branchA, { as: t.owner })));
|
||||||
|
const row = body.prices.find((p: any) => p.productId === t.water20);
|
||||||
|
expect(row).toMatchObject({ defaultPrice: 200, branchPrice: 180 });
|
||||||
|
expect(body.allowed).toBe(true);
|
||||||
|
|
||||||
|
const catalogue = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
|
||||||
|
expect(catalogue.body.products.find((p: any) => p._id === t.water20).unitPrice).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the override when set back to the default, or reset", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await setPrice(t.owner, t.branchA, t.water20, 180);
|
||||||
|
await setPrice(t.owner, t.branchA, t.water20, 200);
|
||||||
|
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
|
||||||
|
|
||||||
|
await setPrice(t.owner, t.branchA, t.water5, 55);
|
||||||
|
const reset = await prices.DELETE(
|
||||||
|
req("DELETE", `/api/branch-prices?branchId=${t.branchA}&productId=${t.water5}`, { as: t.owner })
|
||||||
|
);
|
||||||
|
expect(reset.status).toBe(200);
|
||||||
|
expect(await tillPrice(t.cashierA, t.water5)).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a manager see only their own branch's prices, and not change them", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const own = await json(await prices.GET(req("GET", "/api/branch-prices?branchId=" + t.branchB, { as: t.managerA })));
|
||||||
|
expect(own.body.branchId).toBe(t.branchA); // pinned to their branch
|
||||||
|
expect((await setPrice(t.managerA, t.branchA, t.water20, 150)).status).toBe(403);
|
||||||
|
expect((await prices.GET(req("GET", "/api/branch-prices", { as: t.cashierA }))).status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the plan: Growth and trials may set prices, a paid Starter may only reset them", async () => {
|
||||||
|
const t: Tenant = await makeTenant();
|
||||||
|
await Business.updateOne({ _id: t.businessId }, { tier: "STARTER", subscriptionStatus: "TRIALING" });
|
||||||
|
expect((await setPrice(t.owner, t.branchA, t.water20, 180)).status).toBe(200);
|
||||||
|
|
||||||
|
await Business.updateOne({ _id: t.businessId }, { tier: "STARTER", subscriptionStatus: "ACTIVE" });
|
||||||
|
expect((await setPrice(t.owner, t.branchA, t.water20, 170)).status).toBe(403);
|
||||||
|
// Prices set during the trial keep applying until reset.
|
||||||
|
expect(await tillPrice(t.cashierA, t.water20)).toBe(180);
|
||||||
|
const reset = await prices.DELETE(
|
||||||
|
req("DELETE", `/api/branch-prices?branchId=${t.branchA}&productId=${t.water20}`, { as: t.owner })
|
||||||
|
);
|
||||||
|
expect(reset.status).toBe(200);
|
||||||
|
expect(await tillPrice(t.cashierA, t.water20)).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects zero prices and another tenant's branch or product", async () => {
|
||||||
|
const t = await makeTenant("Alpha");
|
||||||
|
const other = await makeTenant("Beta");
|
||||||
|
expect((await setPrice(t.owner, t.branchA, t.water20, 0)).status).toBe(400);
|
||||||
|
expect((await setPrice(t.owner, other.branchA, t.water20, 150)).status).toBe(404);
|
||||||
|
expect((await setPrice(t.owner, t.branchA, other.water20, 150)).status).toBe(404);
|
||||||
|
expect((await prices.GET(req("GET", "/api/branch-prices?branchId=" + other.branchA, { as: t.owner }))).status).toBe(404);
|
||||||
|
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi, todayNairobi } from "@/lib/server/businessDay";
|
||||||
|
|
||||||
|
describe("Nairobi business day", () => {
|
||||||
|
it("rolls over at midnight EAT, not midnight UTC", () => {
|
||||||
|
// 00:30 EAT on 12 Oct = 21:30 UTC on 11 Oct.
|
||||||
|
expect(todayNairobi(new Date("2026-10-11T21:30:00Z"))).toBe("2026-10-12");
|
||||||
|
// 23:30 EAT on 11 Oct = 20:30 UTC on 11 Oct.
|
||||||
|
expect(todayNairobi(new Date("2026-10-11T20:30:00Z"))).toBe("2026-10-11");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a business date to the UTC range 21:00 -> 21:00", () => {
|
||||||
|
const { start, end } = dayRangeNairobi("2026-10-12");
|
||||||
|
expect(start.toISOString()).toBe("2026-10-11T21:00:00.000Z");
|
||||||
|
expect(end.toISOString()).toBe("2026-10-12T21:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the month on the 1st at 00:00 EAT", () => {
|
||||||
|
expect(startOfMonthNairobi(new Date("2026-10-31T22:00:00Z")).toISOString()).toBe("2026-10-31T21:00:00.000Z");
|
||||||
|
expect(startOfMonthNairobi(new Date("2026-10-15T10:00:00Z")).toISOString()).toBe("2026-09-30T21:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed dates", () => {
|
||||||
|
expect(isBusinessDate("2026-02-30")).toBe(false);
|
||||||
|
expect(isBusinessDate("2026-2-3")).toBe(false);
|
||||||
|
expect(isBusinessDate("2026-02-28")).toBe(true);
|
||||||
|
expect(() => dayRangeNairobi("nope")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stockOf } from "@/lib/server/stock";
|
||||||
|
import { StockTransfer } from "@/lib/server/models/StockTransfer";
|
||||||
|
import { StockLock } from "@/lib/server/models/StockLock";
|
||||||
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||||
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
|
import { makeTenant, req, uuid, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
|
async function openingStock(t: Tenant, quantity: number) {
|
||||||
|
await adjustments.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as: t.owner,
|
||||||
|
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode: "DELTA", quantity },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferOut = (t: Tenant, quantity: number) =>
|
||||||
|
transfers.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as: t.owner,
|
||||||
|
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("concurrent stock changes", () => {
|
||||||
|
it("never lets simultaneous transfers send more than the branch holds", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await openingStock(t, 10);
|
||||||
|
|
||||||
|
// Five managers press "Record transfer" for 4 units at the same moment.
|
||||||
|
const statuses = (await Promise.all(Array.from({ length: 5 }, () => transferOut(t, 4)))).map((r) => r.status);
|
||||||
|
|
||||||
|
expect(statuses.filter((s) => s === 201)).toHaveLength(2);
|
||||||
|
expect(statuses.filter((s) => s === 409)).toHaveLength(3);
|
||||||
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(2);
|
||||||
|
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(8);
|
||||||
|
expect(await db(() => StockTransfer.countDocuments({}))).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds across repeated races, not just once", async () => {
|
||||||
|
for (let round = 0; round < 5; round++) {
|
||||||
|
const t = await makeTenant("Round" + round);
|
||||||
|
await openingStock(t, 9);
|
||||||
|
const statuses = (await Promise.all(Array.from({ length: 4 }, () => transferOut(t, 3)))).map((r) => r.status);
|
||||||
|
expect(statuses.filter((s) => s === 201)).toHaveLength(3);
|
||||||
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a double-tapped transfer once even when both taps race", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await openingStock(t, 10);
|
||||||
|
const body = { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 6 };
|
||||||
|
const statuses = (
|
||||||
|
await Promise.all([
|
||||||
|
transfers.POST(req("POST", "/x", { as: t.owner, body })),
|
||||||
|
transfers.POST(req("POST", "/x", { as: t.owner, body })),
|
||||||
|
])
|
||||||
|
).map((r) => r.status);
|
||||||
|
expect(statuses.sort()).toEqual([200, 201]);
|
||||||
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes over a lock left behind by a crashed request once it expires", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await openingStock(t, 10);
|
||||||
|
await StockLock.create({
|
||||||
|
_id: `${t.businessId}:${t.branchA}:${t.water20}`,
|
||||||
|
owner: "crashed-request",
|
||||||
|
lockedUntil: new Date(Date.now() - 1000),
|
||||||
|
});
|
||||||
|
expect((await transferOut(t, 3)).status).toBe(201);
|
||||||
|
expect(await StockLock.countDocuments({})).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serialises a physical count with a transfer, so the count is never applied to a stale balance", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await openingStock(t, 10);
|
||||||
|
|
||||||
|
// A count of 10 and a transfer of 6 race. Whichever runs second must
|
||||||
|
// see the first: either the count resets 4 back to 10 after the
|
||||||
|
// transfer, or the transfer moves 6 out of the counted 10.
|
||||||
|
const [count, transfer] = await Promise.all([
|
||||||
|
adjustments.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as: t.owner,
|
||||||
|
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode: "COUNT", quantity: 10 },
|
||||||
|
})
|
||||||
|
),
|
||||||
|
transferOut(t, 6),
|
||||||
|
]);
|
||||||
|
expect([count.status, transfer.status]).toEqual([201, 201]);
|
||||||
|
const a = await stockOf(t.businessId, t.branchA, t.water20);
|
||||||
|
expect([4, 10]).toContain(a);
|
||||||
|
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not block transfers of different products or branches on each other", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await openingStock(t, 10);
|
||||||
|
await adjustments.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as: t.owner,
|
||||||
|
body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 10 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const results = await Promise.all([
|
||||||
|
transferOut(t, 5),
|
||||||
|
transfers.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as: t.owner,
|
||||||
|
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water5, quantity: 5 },
|
||||||
|
})
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
expect(results.map((r) => r.status)).toEqual([201, 201]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { costOfGoodsSold } from "@/lib/server/costing";
|
||||||
|
import { StockMovement } from "@/lib/server/models/StockMovement";
|
||||||
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
|
import { startOfMonthNairobi } from "@/lib/server/businessDay";
|
||||||
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
|
import * as purchases from "@/app/api/purchases/route";
|
||||||
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||||
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
|
import * as profit from "@/app/api/reports/profit/route";
|
||||||
|
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
|
||||||
|
|
||||||
|
const ALL_TIME = { from: new Date(0), to: new Date(Date.now() + 86_400_000) };
|
||||||
|
|
||||||
|
const buy = (t: Tenant, branchId: string, quantity: number, unitCost: number, productId = t.water20) =>
|
||||||
|
purchases.POST(req("POST", "/x", { as: t.owner, body: { branchId, supplierName: "Supplier", productId, quantity, unitCost } }));
|
||||||
|
|
||||||
|
const sell = (cashier: Actor, quantity: number, productId: string) =>
|
||||||
|
cash.POST(req("POST", "/x", { as: cashier, body: { sales: [{ clientUuid: uuid(), lines: [{ productId, quantity }] }] } }));
|
||||||
|
|
||||||
|
const adjust = (t: Tenant, mode: "COUNT" | "DELTA", quantity: number) =>
|
||||||
|
adjustments.POST(
|
||||||
|
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode, quantity } })
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Move everything recorded so far to `when` (tests need history in an earlier period). */
|
||||||
|
async function backdate(t: Tenant, when: Date) {
|
||||||
|
// The raw collection: Mongoose treats createdAt as immutable.
|
||||||
|
const filter = { businessId: new mongoose.Types.ObjectId(t.businessId) };
|
||||||
|
await StockMovement.collection.updateMany(filter, { $set: { createdAt: when } });
|
||||||
|
await Transaction.collection.updateMany(filter, { $set: { createdAt: when } });
|
||||||
|
await Purchase.collection.updateMany(filter, { $set: { createdAt: when } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cost of goods sold: FIFO per branch", () => {
|
||||||
|
it("costs the oldest stock first, not an average", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await buy(t, t.branchA, 10, 10);
|
||||||
|
await buy(t, t.branchA, 10, 20);
|
||||||
|
await sell(t.cashierA, 15, t.water20);
|
||||||
|
const c = await costOfGoodsSold(t.businessId, ALL_TIME);
|
||||||
|
expect(c).toMatchObject({ costOfGoodsSold: 200, unitsCosted: 15, unitsWithoutCost: 0 }); // 10x10 + 5x20 (the average would say 225)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("costs each branch at what that branch paid", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await buy(t, t.branchA, 10, 10);
|
||||||
|
await buy(t, t.branchB, 10, 30);
|
||||||
|
await sell(t.cashierB, 2, t.water20);
|
||||||
|
await sell(t.cashierA, 1, t.water20);
|
||||||
|
expect((await costOfGoodsSold(t.businessId, ALL_TIME)).costOfGoodsSold).toBe(70);
|
||||||
|
expect((await costOfGoodsSold(t.businessId, { ...ALL_TIME, branchId: t.branchA })).costOfGoodsSold).toBe(10);
|
||||||
|
expect((await costOfGoodsSold(t.businessId, { ...ALL_TIME, branchId: t.branchB })).costOfGoodsSold).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the sending branch's cost with a transfer", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await buy(t, t.branchA, 10, 10);
|
||||||
|
await transfers.POST(
|
||||||
|
req("POST", "/x", {
|
||||||
|
as: t.owner,
|
||||||
|
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 5 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await buy(t, t.branchB, 5, 30);
|
||||||
|
await sell(t.cashierB, 6, t.water20);
|
||||||
|
// B's oldest stock is the 5 transferred at 10, then 1 of its own at 30.
|
||||||
|
expect((await costOfGoodsSold(t.businessId, { ...ALL_TIME, branchId: t.branchB })).costOfGoodsSold).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("costs a sale made before its stock was recorded by the stock that arrives next", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await sell(t.cashierA, 3, t.water20); // offline sale, nothing on the books yet
|
||||||
|
expect(await costOfGoodsSold(t.businessId, ALL_TIME)).toMatchObject({ costOfGoodsSold: 0, unitsWithoutCost: 3 });
|
||||||
|
await buy(t, t.branchA, 10, 12);
|
||||||
|
expect(await costOfGoodsSold(t.businessId, ALL_TIME)).toMatchObject({ costOfGoodsSold: 36, unitsWithoutCost: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports written-off stock as a loss, not as cost of goods sold, and deducts it in net profit", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await buy(t, t.branchA, 10, 10);
|
||||||
|
await adjust(t, "COUNT", 8);
|
||||||
|
expect(await costOfGoodsSold(t.businessId, ALL_TIME)).toMatchObject({ costOfGoodsSold: 0, stockLosses: 20 });
|
||||||
|
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
|
||||||
|
expect(body).toMatchObject({ stockLosses: 20, grossProfit: 0, netProfit: -20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("values opening stock at the product's purchase price", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await adjust(t, "DELTA", 10); // opening stock, before any purchase was recorded
|
||||||
|
await buy(t, t.branchA, 10, 15);
|
||||||
|
await sell(t.cashierA, 12, t.water20);
|
||||||
|
expect((await costOfGoodsSold(t.businessId, ALL_TIME)).costOfGoodsSold).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts only the period's sales, costed against what was left from earlier periods", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
await buy(t, t.branchA, 10, 10);
|
||||||
|
await sell(t.cashierA, 6, t.water20);
|
||||||
|
const lastMonth = new Date(startOfMonthNairobi().getTime() - 5 * 86_400_000);
|
||||||
|
await backdate(t, lastMonth);
|
||||||
|
await buy(t, t.branchA, 10, 20);
|
||||||
|
await sell(t.cashierA, 6, t.water20); // 4 left from last month at 10, then 2 at 20
|
||||||
|
|
||||||
|
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
|
||||||
|
expect(body).toMatchObject({ costMethod: "FIFO per branch", unitsSold: 6, costOfGoodsSold: 80, unitsWithoutCost: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user