Compare commits

23 Commits

Author SHA1 Message Date
denis_ireri 34ea64c4d4 Merge pull request 'Send platform operators to the platform console after sign-in' (#9) from fix/operator-landing into main
Reviewed-on: #9
2026-10-13 15:06:36 +00:00
denis_ireri d15273e2a6 Send platform operators to the platform console after sign-in
The operator account is the owner of a placeholder business, so sign-in
landed it on that business's empty dashboard with a "Create your first
branch" nudge. Operators now land on /platform (sign-in and the root page),
the dashboard header says "Platform operator", and the nudge is hidden on
the operator's own business.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:04:58 +03:00
denis_ireri 5b7a2b847f Merge pull request 'Add password resets, change password, sign-out, a Branches link and moving staff' (#8) from fix/account-and-navigation-gaps into main
Reviewed-on: #8
2026-10-13 11:04:47 +00:00
denis_ireri 6f8a5fe969 Add password resets, change password, sign-out, a Branches link and moving staff
Gaps found while writing the user manual:

- Forgotten passwords: owners and managers can create a one-time
  password reset link from Staff (managers for their own cashiers). It
  expires after 24 hours; the old password works until it is used, and
  using it signs the account out everywhere. Reset and invite links share
  the invite page, which now says which it is. An owner who forgets their
  password gets a link from the platform operator (Platform console,
  "Owner password link"). The login page's "Forgot password?" explains
  this.
- Change password: any signed-in user, from the dashboard, the till or
  the platform console. Needs the current password; other devices are
  signed out.
- Sign out on the dashboard and the platform console (the till already
  had it), through one shared helper that also clears the till's cached
  session.
- A Branches link on the owner's dashboard; the page was only reachable
  by typing its address.
- Owners can move a manager or cashier to another branch from Staff. A
  moved manager stops being the old branch's manager and becomes the new
  branch's if it has none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:57:04 +03:00
denis_ireri 418ad36b02 Merge pull request 'Cost sales first-in-first-out per branch instead of at an average' (#7) from fix/fifo-cost-of-goods into main
Reviewed-on: #7
2026-10-12 09:54:13 +00:00
denis_ireri ff75cf2fa7 Cost sales first-in-first-out per branch instead of at an average
Cost of goods sold used each product's average buying price across the
whole business. Ten jerrycans bought at 100 and ten at 140, with 15 sold,
cost 1,800 instead of the 1,700 actually paid for those units; a branch
that bought dearer was costed like one that bought cheaper; and units
moved between branches lost their cost.

src/lib/server/costing.ts replays the stock ledger in order with cost
layers per branch and product:
- purchases add stock at their unit cost; transfers carry the sending
  branch's layers to the receiving branch; opening stock and upward counts
  are valued at the product's purchase price at the time;
- sales take the oldest layers first, and that is cost of goods sold;
- stock written off by counts or adjustments is a separate "stock losses"
  figure, now deducted in net profit;
- a sale recorded before its stock (stock gone negative) is costed by the
  next stock to arrive at that branch, even after the period ended; until
  then it is reported as units with no purchase cost on record.

The report shows the method and a Stock losses card. Sales recorded
before the stock ledger existed are counted as uncosted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 12:08:15 +03:00
denis_ireri db9387c01a Merge pull request 'Enforce tenant isolation in the data layer instead of per route' (#6) from fix/enforced-tenant-isolation into main
Reviewed-on: #6
2026-10-12 08:58:31 +00:00
denis_ireri dc500972e0 Merge remote-tracking branch 'origin/main' into fix/enforced-tenant-isolation 2026-10-12 11:55:37 +03:00
denis_ireri 83ec5ecd36 Enforce tenant isolation in the data layer instead of per route
Every route had to remember to add the business filter to every query;
nothing stopped one that forgot. The audit for this change found several
that had: the M-Pesa status re-check, the stock ledger's duplicate checks,
the daily-inventory and branch-price upserts, a cash-sale replay lookup,
several load-by-id lookups, and every populate().

- tenantGuard: a Mongoose plugin on all ten business-scoped models. Any
  find/update/delete/count/distinct, or an aggregate whose first stage is
  not a $match, that does not pin exactly one businessId throws
  TenantScopeError before reaching the database ($in, $exists and other
  operators do not count).
- crossTenant("reason", fn): the explicit, documented exemption for the
  queries that must span businesses - login's email lookup, refresh and
  invite-token lookups, the M-Pesa callback, the platform console and the
  maintenance scripts. git grep crossTenant lists them all.
- scopedPopulate: populated branch, product and staff names are also
  restricted to the business.
- Every flagged query site now names the business (or is exempted with a
  reason).
- Tests: a check that every model with a businessId field carries the
  guard; the guard refusing unscoped queries of every kind; and a route
  sweep where business A sends business B's ids to every read route
  (nothing of B's comes back) and every write route (all refused, B's
  data unchanged). Test-side database inspections go through db(), an
  explicit test exemption. Removing one route's business filter makes the
  suite fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:52:35 +03:00
denis_ireri bae21ee62c Merge pull request 'Make email and phone unique per business, not platform-wide' (#5) from fix/per-business-identity into main
Reviewed-on: #5
2026-10-12 08:41:07 +00:00
denis_ireri 5e0e44c647 Merge pull request 'Stop simultaneous transfers from overdrawing a branch' (#4) from fix/transfer-race into main
Reviewed-on: #4
2026-10-12 08:40:42 +00:00
denis_ireri 21d530cd6d Merge remote-tracking branch 'origin/fix/per-business-identity' into fix/enforced-tenant-isolation 2026-10-12 11:40:38 +03:00
denis_ireri 9e3e59a804 Make email and phone unique per business, not platform-wide
One person could not work for two businesses, and signup and staff
creation told anyone whether an email or phone was registered anywhere
on the platform.

- User email and phone are now unique per business: indexes on
  (businessId, email) and (businessId, phoneNumber). Duplicate checks in
  signup and branch-manager creation are scoped to the business, so they
  no longer reveal other businesses' users.
- Login finds the email in every business and checks the password
  against each account. One match signs straight in; several return 300
  with the businesses to choose from, and the login page shows a picker.
  A wrong password returns the same 401 as before and reveals nothing;
  failures count against each candidate account, and a correct password
  only resets the accounts it opens.
- Platform-operator access now also needs a platformAdmin flag on the
  account. With per-business emails, an email match alone would have let
  any owner create a staff account with the operator's email and open the
  cross-tenant console. The flag is only set by the seed and the
  migration; removing the email from PLATFORM_ADMIN_EMAILS still revokes.
- npm run migrate:identity flags today's operators (while each listed
  email still matches one account) and syncs the User indexes, dropping
  the old unique email_1 and phoneNumber_1. Checked against a database in
  the old shape; safe to re-run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:31:45 +03:00
denis_ireri ee5bb5beb8 Stop simultaneous transfers from overdrawing a branch
A transfer read the sending branch's stock and then wrote its movements
as two separate steps. Requests arriving together all saw the same
balance and all passed the check: five simultaneous transfers of 4 from
a stock of 10 all succeeded, sending 20.

The check and the write now run under withStockLock: a short lease on
one product's stock at one branch, taken with a single atomic upsert
(it only succeeds when no unexpired lease exists) and released when the
request finishes. A second request waits briefly, then sees the updated
balance; after 5 seconds it gets 409 "try again". A lease expires after
10 seconds, so a crashed request cannot block the stock for good.

Physical counts take the same lock, so a count is never computed from a
balance a transfer is changing. A double-tapped transfer that races
itself still moves stock once. Sales do not take the lock: they are
recorded even when stock runs out, by design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:57:17 +03:00
denis_ireri 1fe2421cc4 Merge pull request 'Add a screen to set prices per branch' (#3) from feat/branch-prices into main
Reviewed-on: #3
2026-10-12 07:46:56 +00:00
denis_ireri 0f609d57bb Add a screen to set prices per branch
Branch price overrides were already applied at the till, in cash sales
and in M-Pesa, but nothing could set them.

- Products -> Branch prices (/admin/prices): pick a branch and set its
  price for any product; setting it back to the default removes the
  override, and "Use default" resets it. Branch managers see their own
  branch's prices read-only.
- /api/branch-prices: GET (owner: any branch; manager: own), PUT and
  DELETE (owner only). Branch and product must belong to the business;
  prices must be above zero; who changed a price is recorded.
- Branch pricing is a plan feature, as the Growth plan already
  advertised: Growth and Enterprise, plus any business on its free
  trial. A paid Starter business keeps any prices set during its trial
  and can reset them, but cannot set new ones.
- The catalogue view on the Products page always shows default prices;
  the till's view shows what the branch charges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:45:37 +03:00
denis_ireri 38facd3d9d Merge pull request 'Address the 11 October test report findings' (#2) from fix/test-report-followups into main
Reviewed-on: #2
2026-10-12 07:37:15 +00:00
denis_ireri f865837b27 Address the 11 October test report findings
The initial test report (run before the pilot-readiness changes) raised
seven findings and a retest plan. This closes the ones still open.

- F-01: a business on its free trial may run 2 active branches whatever
  its tier, so an owner can try transfers and per-branch stock. Only
  active branches count towards any limit.
- F-06: owners can rename branches and deactivate/reactivate them from
  the Branches page. A branch can only be deactivated once it holds no
  stock and no active staff; its history stays in every report, and it
  leaves every picker.
- F-04: the Products page lists deactivated products under a toggle with
  Reactivate. Deactivating warns about stock still on hand, which stays
  on the Stock page so it can be transferred or counted out.
- F-05: products carry a unit (L, ml, kg, g, pcs, tray, pack) instead of
  everything being litres.
- CAT-12: product prices must be above zero and names are unique per
  business, ignoring case.
- F-03: a reversed report period is refused by the page and the API, and
  the page labels which period the cards show. Report dates are Nairobi
  business days.
- F-07: cost of goods sold is units sold x average buying price. Stock
  bought in the period is reported separately, with a cash surplus line,
  so unsold stock no longer shows as a loss.
- STA-04: staff and new branch managers can be invited with a one-time,
  7-day link; the person sets their own password. Managers can issue a
  new link for their own cashiers.
- OFF-02 / POS-02: the till shows each product's stock and deducts every
  sale immediately, online or offline; it warns before selling more than
  the recorded stock, and has a Clear sale button.
- npm run backfill:stock turns purchases recorded before the stock ledger
  into stock, so existing tenants (and the report's ZERRO fixtures) start
  from what they bought.

tests/retest-plan.test.ts runs the report's completion and retest plan
and checks every balance and total it predicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:29:21 +03:00
denis_ireri cb962466b3 Merge pull request 'Pilot readiness: server-side roles, stock ledger and transfers, offline-safe sync' (#1) from fix/pilot-readiness into main
Reviewed-on: #1
2026-10-12 06:14:50 +00:00
denis_ireri 8fe50af341 Show Offline when the server is unreachable, and document the pilot changes
The terminal's badge only looked at navigator.onLine, so it said Online
while the server was down. It now also reflects whether the last sync or
health check reached the server. The cart badge no longer covers the
product name.

docs/PILOT_READINESS.md records what changed against the PRD review, the
decisions taken (offline sales may take stock negative, transfers may
not, server price wins but differences are flagged), rollout notes, and
what is still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:54:24 +03:00
denis_ireri 472573a1a8 Make the terminal work offline end to end, and add stock screens
Terminal (PRD 5.3/5.5):
- Opens with no network: a service worker caches the app shell and build
  assets, and the last confirmed session and product list are kept on the
  phone. Only a definitive 401/403 sends the cashier to the login page.
- The offline queue is keyed by cashier and branch, sends only the
  signed-in cashier's sales, chunks to the server's batch limit, and acts
  on the per-sale result: synced, or rejected with the reason shown and
  never retried. Rows from the old queue are adopted on first sync.
- Several products per sale (tap to add), optional customer details, and
  a printable receipt. The banner shows sales waiting to sync and any
  that need attention; sign-out warns about unsynced sales.
- M-Pesa only appears when FEATURE_MPESA is on. A timed-out prompt now
  tells the cashier not to take cash until the payment is checked,
  instead of reporting it as failed.

Admin:
- New Stock page: stock per branch with low/out flags, physical counts
  and adjustments, inter-branch transfers, morning dispatch and evening
  count entry, and recent movements.
- Dashboard shows stock on hand per branch and links to Stock.
- Staff page can deactivate and reactivate accounts.
- Products page is read-only for branch managers and edits the low-stock
  alert level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:46:23 +03:00
denis_ireri 8817c0f52f Add a stock ledger, inter-branch transfers and a sync protocol that cannot jam
Stock (PRD 5.2): a branch's stock of a product is now the sum of its
StockMovement rows. Every movement names the document that caused it and
(type, refId) is unique, so replaying a sale, retrying a transfer or a
second M-Pesa callback never moves the same stock twice.

- Cash sales and confirmed M-Pesa payments deduct stock; purchases of a
  catalogue product add it.
- POST /api/stock/transfers moves stock between two branches (owner: any
  two; manager: out of their own) and refuses to send more than the
  sending branch holds. Double submits are applied once.
- POST /api/stock/adjustments records opening stock, breakage or a
  physical count (the ledger is corrected to the counted number).
- GET /api/stock returns every branch x product with LOW/OUT flags
  (per-product lowStockThreshold, default 10); GET /api/stock/movements
  is the history.

Sync (PRD 5.3/5.5): POST /api/transactions/cash now returns a result per
sale - saved, duplicate or rejected - instead of silently skipping
replays. The old client never learned a replayed sale had synced, so
rows piled up until the batch passed the 100-sale limit and every flush
failed with 400. Also:
- A sale can hold several products (one Transaction per line, grouped by
  saleId); the old single-product payload is still accepted.
- One bad sale is rejected on its own instead of failing the batch.
- A queued sale is only accepted from the cashier who recorded it, and
  stays with the branch where it happened.
- A product deactivated while offline is still recorded at its price; an
  invalid phone is dropped rather than losing the sale; the phone is now
  optional for cash sales.
- The price the device showed is kept and a difference is flagged
  (priceMismatch) instead of being silently overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:39:11 +03:00
denis_ireri d4808b3e46 Enforce roles and tenant status on the server, and use the Kenyan business day
The admin pages redirected cashiers away, but the API itself let them read
profit, expenses and purchases and record expenses and purchases. Branch
managers could also change product prices for every branch. Every gated
route now goes through authorize(), which answers 401 when signed out and
403 for the wrong role.

- Owners and managers only: profit report, expenses, purchases, users,
  morning dispatch and evening count. Owner only: create and edit products,
  rename branches.
- PATCH /api/users/:id deactivates or reactivates staff (managers: their own
  cashiers only) and revokes the user's device sessions. PATCH
  /api/branches/:id renames a branch.
- Login and token refresh refuse inactive or SUSPENDED businesses.
- Public signup refuses PLATFORM_ADMIN_EMAILS addresses, so an unregistered
  operator email can no longer be claimed.
- M-Pesa sits behind FEATURE_MPESA (off by default, out of scope for the
  pilot): its routes return 404 and the Daraja variables are optional.
  Turning it on in production requires the callback IP allowlist.
- "Today" and the default month now follow Africa/Nairobi instead of the
  server's UTC clock, so the business day no longer starts at 03:00 EAT.
- Malformed ids in query strings and bodies return 400 instead of a 500,
  the inventory routes check the product belongs to the tenant, and the
  reconciliation lookups are tenant-scoped.
- Branch-scoped staff see their branch's override price from GET /api/products.
- Adds a vitest suite against an in-memory MongoDB (npm test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:34:35 +03:00
111 changed files with 9204 additions and 733 deletions
+19 -1
View File
@@ -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
+209
View File
@@ -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.
+1778 -1
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -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"
} }
} }
+86
View File
@@ -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;
})
);
}
});
+30
View File
@@ -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);
});
+2 -1
View File
@@ -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);
}); });
+50
View File
@@ -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
View File
@@ -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);
}); });
+109
View File
@@ -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
View File
@@ -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&apos;s manager login now Create this branch&apos;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 &amp; 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>
+28 -5
View File
@@ -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
+278
View File
@@ -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
View File
@@ -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 &amp; pricing</h1> <h1 className="font-display text-xl font-bold">Products &amp; 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&apos;s terminal. Deactivating a size hides Sizes shown here appear as options on every branch&apos;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>
); );
+80 -21
View File
@@ -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
View File
@@ -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>
+462
View File
@@ -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>
);
}
+72 -19
View File
@@ -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());
+8 -6
View File
@@ -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() },
}); });
} }
+45
View File
@@ -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 });
}
+10 -1
View File
@@ -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();
+12 -9
View File
@@ -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 });
+131
View File
@@ -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 });
}
+77
View File
@@ -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 });
}
+27 -19
View File
@@ -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 });
} }
+29 -10
View File
@@ -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),
+9 -13
View File
@@ -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),
+19 -13
View File
@@ -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);
+19 -17
View File
@@ -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 });
+19 -17
View File
@@ -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 });
+62
View File
@@ -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 });
}
+8 -1
View File
@@ -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);
} }
} }
+2
View File
@@ -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 });
}
+18 -13
View File
@@ -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,
+18 -7
View File
@@ -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 }
); );
+36 -12
View File
@@ -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 });
} }
+35 -14
View File
@@ -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 });
} }
+110 -47
View File
@@ -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 };
}
+92
View File
@@ -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;
}
}
+32
View File
@@ -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 });
}
+24
View File
@@ -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 });
}
+153
View File
@@ -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;
}
}
+163 -64
View File
@@ -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 });
} }
+11 -8
View File
@@ -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 });
+42
View File
@@ -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 });
}
+94
View File
@@ -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
View File
@@ -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;
} }
+22
View File
@@ -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;
}
}
+116
View File
@@ -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>
);
}
+2
View File
@@ -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
View File
@@ -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}
+13
View File
@@ -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
View File
@@ -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");
+59
View File
@@ -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
View File
@@ -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={() => {
+26
View File
@@ -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>
);
}
+18
View File
@@ -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;
}
+1 -1
View File
@@ -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}
+60
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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";
} }
+40
View File
@@ -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;
}
+194
View File
@@ -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
View File
@@ -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!,
};
}
+37
View File
@@ -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}`;
}
+13
View File
@@ -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);
+3
View File
@@ -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);
+3
View File
@@ -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);
+12 -1
View File
@@ -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);
+3
View File
@@ -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);
+25
View File
@@ -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);
+54
View File
@@ -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);
+43
View File
@@ -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);
+18 -3
View File
@@ -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>) ||
+33 -2
View File
@@ -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 -1
View File
@@ -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"
+16 -5
View File
@@ -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 };
} }
+21
View File
@@ -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.`;
}
+235
View File
@@ -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 });
}
}
+27 -10
View File
@@ -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;
} }
@@ -53,3 +55,18 @@ export async function loadTier(businessId: Types.ObjectId | string): Promise<Tie
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;
}
+114
View File
@@ -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 } };
}
+8
View File
@@ -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" });
+58
View File
@@ -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
}
}
+16
View File
@@ -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();
}
+11
View File
@@ -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 };
+26
View File
@@ -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 {
+25 -1
View File
@@ -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;
@@ -84,3 +86,25 @@ export interface ReconciliationRow {
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;
}
+20
View File
@@ -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}`;
}
+166
View File
@@ -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");
});
});
+147
View File
@@ -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");
});
});
+90
View File
@@ -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);
});
});
+29
View File
@@ -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();
});
});
+119
View File
@@ -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]);
});
});
+110
View File
@@ -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