Compare commits
12 Commits
5e0e44c647
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 34ea64c4d4 | |||
| d15273e2a6 | |||
| 5b7a2b847f | |||
| 6f8a5fe969 | |||
| 418ad36b02 | |||
| ff75cf2fa7 | |||
| db9387c01a | |||
| dc500972e0 | |||
| 83ec5ecd36 | |||
| bae21ee62c | |||
| 21d530cd6d | |||
| 9e3e59a804 |
+4
-2
@@ -13,8 +13,10 @@ JWT_REFRESH_TTL=30d
|
|||||||
DELIVERY_MAX_DEVICES=2
|
DELIVERY_MAX_DEVICES=2
|
||||||
|
|
||||||
# ---- Platform operator ----
|
# ---- Platform operator ----
|
||||||
# Comma-separated emails with cross-tenant access to /platform. Create these
|
# Comma-separated emails with cross-tenant access to /platform. The account
|
||||||
# accounts with `npm run seed`; public signup refuses these addresses.
|
# 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=
|
PLATFORM_ADMIN_EMAILS=
|
||||||
|
|
||||||
# ---- Invite links ----
|
# ---- Invite links ----
|
||||||
|
|||||||
+71
-9
@@ -98,7 +98,7 @@ seven findings and a retest plan. All of them are addressed on
|
|||||||
| 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-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-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-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 is now units sold x the product's average buying price. Purchases in the period are shown separately as "Stock purchased", with a "Cash surplus" line. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| OFF-02 Local stock deduction | The till shows each product's stock and deducts every sale immediately, online or offline. |
|
||||||
@@ -131,15 +131,77 @@ prices read-only.
|
|||||||
mismatch.
|
mismatch.
|
||||||
- The Products page always shows default prices.
|
- 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
|
## Still open
|
||||||
- **Global email and phone uniqueness.** One person cannot work for two
|
|
||||||
businesses, and signup reveals whether an address is already registered.
|
|
||||||
- **Isolation by convention.** Tenant filters are still written per route.
|
|
||||||
The tests cover the sales, stock, transfer and access paths, not every
|
|
||||||
route.
|
|
||||||
- **Cost of goods is an average.** It uses each product's average buying
|
|
||||||
price across the business, not first-in-first-out per branch. Units sold
|
|
||||||
with no purchase on record are counted and flagged, but have no cost.
|
|
||||||
- **M-Pesa, if turned on.** Only one product per payment. A prompt that
|
- **M-Pesa, if turned on.** Only one product per payment. A prompt that
|
||||||
times out still needs a manual check before taking cash.
|
times out still needs a manual check before taking cash.
|
||||||
- **Not modelled yet:** email verification at signup, sending invites by
|
- **Not modelled yet:** email verification at signup, sending invites by
|
||||||
|
|||||||
+2
-1
@@ -11,7 +11,8 @@
|
|||||||
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
|
"migrate:multitenant": "tsx scripts/migrate-multitenant.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"backfill:stock": "tsx scripts/backfill-stock-from-purchases.ts"
|
"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",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Expense } from "../src/lib/server/models/Expense";
|
|||||||
import { Purchase } from "../src/lib/server/models/Purchase";
|
import { Purchase } from "../src/lib/server/models/Purchase";
|
||||||
import { Business } from "../src/lib/server/models/Business";
|
import { Business } from "../src/lib/server/models/Business";
|
||||||
import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers";
|
import { DEFAULT_TIER, TRIAL_DAYS } from "../src/lib/tiers";
|
||||||
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
||||||
|
|
||||||
async function migrate() {
|
async function migrate() {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
@@ -75,7 +76,7 @@ async function migrate() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
migrate().catch((err) => {
|
crossTenant("one-off migration of data from before businesses existed", migrate).catch((err) => {
|
||||||
console.error("migration failed:", err.message);
|
console.error("migration failed:", err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import path from "path";
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, "../.env.local") });
|
||||||
|
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { connectDb } from "../src/lib/server/db";
|
||||||
|
import { User } from "../src/lib/server/models/User";
|
||||||
|
import { platformAdminEmails } from "../src/lib/server/platform";
|
||||||
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-off, safe to re-run. Moves user identity from platform-wide to
|
||||||
|
* per-business uniqueness:
|
||||||
|
*
|
||||||
|
* 1. Marks today's platform operators. Until now an operator was anyone
|
||||||
|
* whose email was in PLATFORM_ADMIN_EMAILS; emails were unique, so
|
||||||
|
* that was one account each. From now on emails are only unique per
|
||||||
|
* business, so access also needs the platformAdmin flag on the account.
|
||||||
|
* Run this BEFORE dropping the old index, while each listed email
|
||||||
|
* still matches exactly one account.
|
||||||
|
* 2. Syncs the User indexes: drops the old unique `email_1` and
|
||||||
|
* `phoneNumber_1`, creates unique (businessId, email) and
|
||||||
|
* (businessId, phoneNumber), and a plain email lookup index.
|
||||||
|
*
|
||||||
|
* npm run migrate:identity
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
await connectDb();
|
||||||
|
|
||||||
|
for (const email of platformAdminEmails()) {
|
||||||
|
const accounts = await User.find({ email }).select("_id").lean();
|
||||||
|
if (accounts.length === 1) {
|
||||||
|
await User.updateOne({ _id: accounts[0]._id }, { $set: { platformAdmin: true } });
|
||||||
|
console.log(`Platform operator: ${email}`);
|
||||||
|
} else if (accounts.length === 0) {
|
||||||
|
console.warn(`No account for ${email} - create it with npm run seed.`);
|
||||||
|
} else {
|
||||||
|
console.warn(`${email} has ${accounts.length} accounts - set platformAdmin on the right one by hand.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropped = await User.syncIndexes();
|
||||||
|
console.log(`User indexes synced. Dropped: ${dropped.length ? dropped.join(", ") : "none"}.`);
|
||||||
|
await mongoose.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
crossTenant("identity migration spans every business", main).catch((err) => {
|
||||||
|
console.error("migration failed:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+18
-2
@@ -13,6 +13,8 @@ import { connectDb } from "../src/lib/server/db";
|
|||||||
import { User } from "../src/lib/server/models/User";
|
import { User } from "../src/lib/server/models/User";
|
||||||
import { Product } from "../src/lib/server/models/Product";
|
import { Product } from "../src/lib/server/models/Product";
|
||||||
import { Business } from "../src/lib/server/models/Business";
|
import { Business } from "../src/lib/server/models/Business";
|
||||||
|
import { isPlatformAdminEmail } from "../src/lib/server/platform";
|
||||||
|
import { crossTenant } from "../src/lib/server/tenantGuard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run once against a fresh database: `npm run seed`.
|
* Run once against a fresh database: `npm run seed`.
|
||||||
@@ -39,10 +41,23 @@ async function seed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let business = await Business.findOne({ name: demoBusinessName });
|
let business = await Business.findOne({ name: demoBusinessName });
|
||||||
const existing = await User.findOne({ email });
|
// Emails are unique per business, so look in the seeded business (or, for
|
||||||
|
// a legacy single-tenant database, for its one super admin).
|
||||||
|
const existing = business
|
||||||
|
? await User.findOne({ email, businessId: business._id })
|
||||||
|
: await User.findOne({ email, role: "SUPER_ADMIN" });
|
||||||
|
// The seed is the only way (besides the identity migration) to make an
|
||||||
|
// operator: the flag is honoured only while the email is also listed in
|
||||||
|
// PLATFORM_ADMIN_EMAILS.
|
||||||
|
const platformAdmin = isPlatformAdminEmail(email);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`Super admin (${email}) already exists, skipping`);
|
console.log(`Super admin (${email}) already exists, skipping`);
|
||||||
|
if (platformAdmin && !existing.platformAdmin) {
|
||||||
|
existing.platformAdmin = true;
|
||||||
|
await existing.save();
|
||||||
|
console.log(`Marked ${email} as a platform operator`);
|
||||||
|
}
|
||||||
if (!business) {
|
if (!business) {
|
||||||
// Backfill a business for a legacy single-tenant admin if needed.
|
// Backfill a business for a legacy single-tenant admin if needed.
|
||||||
business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
|
business = await Business.create({ name: demoBusinessName, ownerId: existing._id });
|
||||||
@@ -61,6 +76,7 @@ async function seed() {
|
|||||||
phoneNumber: "254700000000",
|
phoneNumber: "254700000000",
|
||||||
email,
|
email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
|
platformAdmin,
|
||||||
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
|
businessId: new mongoose.Types.ObjectId(), // temporary, overwritten below
|
||||||
});
|
});
|
||||||
business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
|
business = await Business.create({ name: demoBusinessName, ownerId: owner._id });
|
||||||
@@ -92,7 +108,7 @@ async function seed() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
seed().catch((err) => {
|
crossTenant("seeding may find a legacy admin outside any business", seed).catch((err) => {
|
||||||
console.error("seed failed:", err.message);
|
console.error("seed failed:", err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
|
import { AuthUser } from "@/lib/types";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"tap-target w-full rounded-xl2 border border-depth-200 bg-white px-4 text-depth-900 shadow-sm transition focus:border-flow-500 focus:outline-none focus:ring-2 focus:ring-flow-500/25";
|
||||||
|
|
||||||
|
/** The signed-in user changes their own password. */
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [me, setMe] = useState<AuthUser | null>(null);
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiFetch<{ user: AuthUser }>("/api/auth/me")
|
||||||
|
.then((r) => setMe(r.user))
|
||||||
|
.catch(() => router.replace("/login"));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const home = me?.role === "DELIVERY" ? "/terminal" : "/admin/dashboard";
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (next.length < 8) return setError("The new password must be at least 8 characters.");
|
||||||
|
if (next !== confirm) return setError("The two new passwords do not match.");
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiFetch("/api/auth/password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ currentPassword: current, newPassword: next }),
|
||||||
|
});
|
||||||
|
setDone(true);
|
||||||
|
setCurrent("");
|
||||||
|
setNext("");
|
||||||
|
setConfirm("");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Could not change your password.");
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-sand-50 px-5 py-10">
|
||||||
|
<div className="w-full max-w-md rounded-3xl bg-white p-6 shadow-soft">
|
||||||
|
<h1 className="font-display text-2xl font-bold text-depth-900">Change password</h1>
|
||||||
|
{me && <p className="mt-1 text-sm text-depth-600">{me.email}</p>}
|
||||||
|
|
||||||
|
{done ? (
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600">
|
||||||
|
Your password is changed. Any other phone or computer signed in to your account has been signed out.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.replace(home)}
|
||||||
|
className="tap-target mt-4 w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="current-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Current password
|
||||||
|
</label>
|
||||||
|
<input id="current-password" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
New password
|
||||||
|
</label>
|
||||||
|
<input id="new-password" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-password" className="mb-1.5 block text-sm font-medium text-depth-700">
|
||||||
|
Confirm new password
|
||||||
|
</label>
|
||||||
|
<input id="confirm-password" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="rounded-lg bg-alert-500/10 px-3 py-2 text-sm text-alert-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="tap-target w-full rounded-full bg-gradient-to-r from-flow-500 to-flow-700 font-display font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : "Change password"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => router.replace(home)} className="w-full text-sm text-depth-500 underline">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ 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 StockSummaryCard from "@/components/admin/StockSummaryCard";
|
||||||
|
import AccountLinks from "@/components/AccountLinks";
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -112,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">
|
||||||
@@ -129,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
|
||||||
@@ -137,7 +146,7 @@ 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>
|
||||||
@@ -145,6 +154,11 @@ export default function AdminDashboardPage() {
|
|||||||
<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">
|
<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
|
Stock
|
||||||
</Link>
|
</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">
|
||||||
Products
|
Products
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface ProfitReport {
|
|||||||
unitsSold: number;
|
unitsSold: number;
|
||||||
costOfGoodsSold: number;
|
costOfGoodsSold: number;
|
||||||
unitsWithoutCost: number;
|
unitsWithoutCost: number;
|
||||||
|
stockLosses: number;
|
||||||
grossProfit: number;
|
grossProfit: number;
|
||||||
expenses: number;
|
expenses: number;
|
||||||
expenseCount: number;
|
expenseCount: number;
|
||||||
@@ -121,8 +122,9 @@ export default function ReportsPage() {
|
|||||||
[" M-Pesa", report.mpesaTotal],
|
[" M-Pesa", report.mpesaTotal],
|
||||||
["Sales count", report.salesCount],
|
["Sales count", report.salesCount],
|
||||||
["Units sold", report.unitsSold],
|
["Units sold", report.unitsSold],
|
||||||
["Cost of goods sold (average purchase cost)", report.costOfGoodsSold],
|
["Cost of goods sold (FIFO per branch)", report.costOfGoodsSold],
|
||||||
["Units sold with no purchase cost on record", report.unitsWithoutCost],
|
["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],
|
||||||
@@ -248,7 +250,7 @@ export default function ReportsPage() {
|
|||||||
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods sold</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.costOfGoodsSold)}</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">
|
<p className="mt-1 text-xs text-depth-500">
|
||||||
{report.unitsSold} units sold x average buying price
|
{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`}
|
{report.unitsWithoutCost > 0 && ` · ${report.unitsWithoutCost} units have no purchase cost on record`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,11 +274,11 @@ 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>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<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">
|
<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="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 font-display text-2xl font-bold text-depth-900">{money(report.stockPurchases)}</p>
|
||||||
@@ -290,6 +292,15 @@ export default function ReportsPage() {
|
|||||||
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.cashSurplus)}</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>
|
<p className="mt-1 text-xs text-depth-500">Sales − stock purchased − expenses</p>
|
||||||
</div>
|
</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 className="flex items-center justify-between rounded-xl2 border border-depth-200 bg-sand-100 px-4 py-3">
|
<div className="flex items-center justify-between rounded-xl2 border border-depth-200 bg-sand-100 px-4 py-3">
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function StaffPage() {
|
|||||||
// Inviting is the default: the person sets their own password, so the
|
// Inviting is the default: the person sets their own password, so the
|
||||||
// owner never has to know or pass on anyone's password.
|
// owner never has to know or pass on anyone's password.
|
||||||
const [setPasswordNow, setSetPasswordNow] = useState(false);
|
const [setPasswordNow, setSetPasswordNow] = useState(false);
|
||||||
const [inviteLink, setInviteLink] = useState<{ name: string; url: string } | null>(null);
|
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";
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ export default function StaffPage() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
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 } : null);
|
setInviteLink(result.inviteUrl ? { name: name.trim(), url: result.inviteUrl, purpose: "invite" } : null);
|
||||||
setName("");
|
setName("");
|
||||||
setBranchId("");
|
setBranchId("");
|
||||||
setPhoneNumber("");
|
setPhoneNumber("");
|
||||||
@@ -121,13 +121,35 @@ export default function StaffPage() {
|
|||||||
return s.role === "DELIVERY" && s.branchId === me.branchId;
|
return s.role === "DELIVERY" && s.branchId === me.branchId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function newInviteLink(s: StaffMember) {
|
// 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 {
|
try {
|
||||||
const result = await apiFetch<{ inviteUrl: string }>("/api/users/" + s._id + "/invite", { method: "POST" });
|
const result = await apiFetch<{ inviteUrl: string; purpose: "invite" | "reset" }>("/api/users/" + s._id + "/invite", {
|
||||||
setInviteLink({ name: s.name, url: result.inviteUrl });
|
method: "POST",
|
||||||
show("New invite link created. The old link no longer works.", "success");
|
});
|
||||||
|
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) {
|
} catch (err) {
|
||||||
show(err instanceof ApiError ? err.message : "Could not create an invite link", "error");
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,8 +241,15 @@ export default function StaffPage() {
|
|||||||
|
|
||||||
{inviteLink && (
|
{inviteLink && (
|
||||||
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm text-depth-800">
|
<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="font-medium">
|
||||||
<p className="mt-1 text-xs text-depth-500">It works once and expires in 7 days. Anyone with the link can claim the account, so send it only to them.</p>
|
{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
|
<input
|
||||||
readOnly
|
readOnly
|
||||||
value={inviteLink.url}
|
value={inviteLink.url}
|
||||||
@@ -233,7 +262,7 @@ export default function StaffPage() {
|
|||||||
Copy link
|
Copy link
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={`https://wa.me/?text=${encodeURIComponent(`Set your Vessel password here: ${inviteLink.url}`)}`}
|
href={`https://wa.me/?text=${encodeURIComponent(`${inviteLink.purpose === "reset" ? "Reset" : "Set"} your Vessel password here: ${inviteLink.url}`)}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
|
className="rounded-lg border border-depth-200 bg-white px-3 py-1.5 text-xs font-semibold text-depth-700"
|
||||||
@@ -262,7 +291,7 @@ 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 gap-3 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 className={s.isActive ? "" : "opacity-50"}>
|
<div className={s.isActive ? "" : "opacity-50"}>
|
||||||
<p className="font-medium text-depth-900">
|
<p className="font-medium text-depth-900">
|
||||||
{s.name}
|
{s.name}
|
||||||
@@ -276,16 +305,33 @@ export default function StaffPage() {
|
|||||||
{branchName ? " · " + branchName : ""}
|
{branchName ? " · " + branchName : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
|
<span className={"rounded-full px-2.5 py-1 text-xs font-medium " + roleBadgeStyles[s.role]}>
|
||||||
{s.role.replace("_", " ")}
|
{s.role.replace("_", " ")}
|
||||||
</span>
|
</span>
|
||||||
{canManage(s) && s.isActive && s.invitePending && (
|
{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
|
<button
|
||||||
onClick={() => newInviteLink(s)}
|
onClick={() => newPasswordLink(s)}
|
||||||
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
|
className="rounded-lg border border-depth-200 px-2.5 py-1 text-xs font-medium text-depth-700"
|
||||||
>
|
>
|
||||||
New link
|
{s.invitePending ? "New link" : "Reset password"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canManage(s) && (
|
{canManage(s) && (
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ 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";
|
import { tenantAccessError } from "@/lib/server/subscription";
|
||||||
|
|
||||||
@@ -21,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) {
|
||||||
@@ -44,32 +49,72 @@ 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);
|
const tenantError = await tenantAccessError(user.businessId);
|
||||||
if (tenantError) {
|
if (tenantError) {
|
||||||
@@ -107,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({
|
||||||
@@ -122,8 +167,10 @@ export async function POST(req: NextRequest) {
|
|||||||
phoneNumber: user.phoneNumber,
|
phoneNumber: user.phoneNumber,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
// Platform operators start on the console for every business, not on
|
||||||
isPlatformAdmin: isPlatformAdminEmail(user.email),
|
// their own (placeholder) business's dashboard.
|
||||||
|
redirectTo: isPlatformAdmin(user) ? "/platform" : user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
||||||
|
isPlatformAdmin: isPlatformAdmin(user),
|
||||||
});
|
});
|
||||||
|
|
||||||
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 { isMpesaEnabled } from "@/lib/server/env";
|
import { isMpesaEnabled } from "@/lib/server/env";
|
||||||
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||||
|
|
||||||
@@ -16,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";
|
||||||
@@ -38,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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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 { 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";
|
||||||
@@ -24,7 +25,9 @@ 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 });
|
||||||
|
|||||||
@@ -53,22 +53,16 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const lowerEmail = email.toLowerCase();
|
const lowerEmail = email.toLowerCase();
|
||||||
|
|
||||||
// Platform-operator rights are granted by email (PLATFORM_ADMIN_EMAILS)
|
// Operator addresses are reserved, so nobody can open a business that
|
||||||
// and signup does not verify email ownership, so an operator address
|
// looks like the operator's. (Access itself also needs the platformAdmin
|
||||||
// must never be claimable through public signup. Operator accounts are
|
// flag, which signup never sets.)
|
||||||
// created with `npm run seed` instead.
|
|
||||||
if (isPlatformAdminEmail(lowerEmail)) {
|
if (isPlatformAdminEmail(lowerEmail)) {
|
||||||
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
|
return NextResponse.json({ error: "This email cannot be used to sign up." }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailTaken = await User.findOne({ email: lowerEmail }).lean();
|
// No platform-wide duplicate check: a new business has no users yet, and
|
||||||
if (emailTaken) {
|
// the same person may already work for another business. (Checking would
|
||||||
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
|
// also tell anyone whether an email or phone is registered elsewhere.)
|
||||||
}
|
|
||||||
const phoneTaken = await User.findOne({ phoneNumber: normalizedPhone }).lean();
|
|
||||||
if (phoneTaken) {
|
|
||||||
return NextResponse.json({ error: "An account with this phone number already exists." }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
@@ -100,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 });
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export async function PUT(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const row = await BranchPricing.findOneAndUpdate(
|
const row = await BranchPricing.findOneAndUpdate(
|
||||||
{ branchId, productId },
|
{ businessId: auth.businessId, branchId, productId },
|
||||||
{ businessId: auth.businessId, unitPrice, updatedBy: auth.sub },
|
{ businessId: auth.businessId, unitPrice, updatedBy: auth.sub },
|
||||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||||
).lean();
|
).lean();
|
||||||
|
|||||||
@@ -71,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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +106,9 @@ export async function POST(req: NextRequest) {
|
|||||||
{ 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
|
|||||||
import { Branch } from "@/lib/server/models/Branch";
|
import { Branch } from "@/lib/server/models/Branch";
|
||||||
import { objectId } from "@/lib/server/validation";
|
import { objectId } from "@/lib/server/validation";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
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";
|
||||||
@@ -36,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();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const date = parsed.data.date ?? todayNairobi();
|
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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const date = parsed.data.date ?? todayNairobi();
|
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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,14 +4,18 @@ import bcrypt from "bcryptjs";
|
|||||||
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 { Business } from "@/lib/server/models/Business";
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { UserSession } from "@/lib/server/models/UserSession";
|
||||||
import { hashInviteToken } from "@/lib/server/invites";
|
import { hashInviteToken } from "@/lib/server/invites";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
async function findInvitee(token: string) {
|
async function findInvitee(token: string) {
|
||||||
if (!token || token.length > 100) return null;
|
if (!token || token.length > 100) return null;
|
||||||
const user = await User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true });
|
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;
|
if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null;
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -24,7 +28,12 @@ export async function GET(_req: NextRequest, { params }: { params: { token: stri
|
|||||||
const user = await findInvitee(params.token);
|
const user = await findInvitee(params.token);
|
||||||
if (!user) return NextResponse.json(INVALID, { status: 404 });
|
if (!user) return NextResponse.json(INVALID, { status: 404 });
|
||||||
const business = await Business.findById(user.businessId).select("name").lean();
|
const business = await Business.findById(user.businessId).select("name").lean();
|
||||||
return NextResponse.json({ name: user.name, email: user.email, businessName: business?.name ?? null });
|
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") });
|
const acceptSchema = z.object({ password: z.string().min(8, "Password must be at least 8 characters") });
|
||||||
@@ -42,8 +51,12 @@ export async function POST(req: NextRequest, { params }: { params: { token: stri
|
|||||||
user.passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
user.passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
user.inviteTokenHash = null;
|
user.inviteTokenHash = null;
|
||||||
user.inviteExpiresAt = null;
|
user.inviteExpiresAt = null;
|
||||||
|
user.invitePurpose = null;
|
||||||
user.failedLoginAttempts = 0;
|
user.failedLoginAttempts = 0;
|
||||||
user.lockedUntil = null;
|
user.lockedUntil = null;
|
||||||
await user.save();
|
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 });
|
return NextResponse.json({ ok: true, email: user.email });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { connectDb } from "@/lib/server/db";
|
|||||||
import { isMpesaEnabled } from "@/lib/server/env";
|
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 { 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";
|
||||||
@@ -69,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({
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ 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;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isValidObjectId } from "mongoose";
|
||||||
|
import { connectDb } from "@/lib/server/db";
|
||||||
|
import { Business } from "@/lib/server/models/Business";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { requirePlatformAdmin } from "@/lib/server/platform";
|
||||||
|
import { invitePending, issueInvite } from "@/lib/server/invites";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/platform/businesses/:id/owner-reset — operator only. A one-time
|
||||||
|
* password link for a business's owner of record: the way back in for an
|
||||||
|
* owner who has forgotten their password (nobody inside the business can
|
||||||
|
* reset the owner). Send the link to the owner yourself, by a channel you
|
||||||
|
* know is theirs. Their current password keeps working until it is used.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
await connectDb();
|
||||||
|
const admin = await requirePlatformAdmin(req);
|
||||||
|
if (!admin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "Business not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const business = await Business.findById(params.id).select("ownerId").lean();
|
||||||
|
if (!business) return NextResponse.json({ error: "Business not found" }, { status: 404 });
|
||||||
|
const owner = await User.findOne({ _id: business.ownerId, businessId: business._id });
|
||||||
|
if (!owner) return NextResponse.json({ error: "This business has no owner account" }, { status: 404 });
|
||||||
|
if (owner._id.toString() === admin.userId) {
|
||||||
|
return NextResponse.json({ error: "Change your own password from Change password instead" }, { status: 409 });
|
||||||
|
}
|
||||||
|
if (!owner.isActive) return NextResponse.json({ error: "The owner's account is deactivated" }, { status: 409 });
|
||||||
|
|
||||||
|
const purpose = invitePending(owner) ? "invite" : "reset";
|
||||||
|
const inviteUrl = issueInvite(owner, req, purpose);
|
||||||
|
await owner.save();
|
||||||
|
return NextResponse.json({ inviteUrl, purpose, email: owner.email, name: owner.name, expiresAt: owner.inviteExpiresAt });
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ 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 { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -23,23 +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 } } },
|
||||||
{ $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) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
|||||||
import { objectId } from "@/lib/server/validation";
|
import { objectId } from "@/lib/server/validation";
|
||||||
import { recordMovement } from "@/lib/server/stock";
|
import { recordMovement } from "@/lib/server/stock";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
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";
|
||||||
@@ -36,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();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Purchase } from "@/lib/server/models/Purchase";
|
|||||||
import { Expense } from "@/lib/server/models/Expense";
|
import { Expense } from "@/lib/server/models/Expense";
|
||||||
import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi } from "@/lib/server/businessDay";
|
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";
|
||||||
@@ -15,11 +16,13 @@ export const dynamic = "force-dynamic";
|
|||||||
*
|
*
|
||||||
* Management overview, NOT accounting (full accounting is out of the PRD's scope).
|
* Management overview, NOT accounting (full accounting is out of the PRD's scope).
|
||||||
* Revenue SUCCESS sales in the period
|
* Revenue SUCCESS sales in the period
|
||||||
* Cost of goods sold units sold x that product's average purchase cost
|
* Cost of goods sold first-in-first-out per branch: each unit sold costs
|
||||||
* (all purchases up to the end of the period)
|
* what that branch paid for its oldest stock on hand
|
||||||
|
* (see src/lib/server/costing.ts)
|
||||||
* Gross profit revenue - cost of goods sold
|
* Gross profit revenue - cost of goods sold
|
||||||
* Expenses operating expenses in the period
|
* Expenses operating expenses in the period
|
||||||
* Net profit gross profit - expenses
|
* 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:
|
* Shown separately, because buying stock is not the same as using it:
|
||||||
* Stock purchased cash spent on stock in the period
|
* Stock purchased cash spent on stock in the period
|
||||||
* Cash surplus revenue - stock purchased - expenses
|
* Cash surplus revenue - stock purchased - expenses
|
||||||
@@ -47,7 +50,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
||||||
const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) };
|
const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) };
|
||||||
|
|
||||||
const [salesByProduct, avgCosts, purchaseAgg, expenseAgg] = await Promise.all([
|
const [salesByProduct, cost, purchaseAgg, expenseAgg] = await Promise.all([
|
||||||
Transaction.aggregate<{
|
Transaction.aggregate<{
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
@@ -68,12 +71,7 @@ export async function GET(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
// Average buying price per product across the business (suppliers
|
fifoCost(auth.businessId, { from, to, branchId }),
|
||||||
// charge every branch alike), using everything bought up to period end.
|
|
||||||
Purchase.aggregate<{ _id: Types.ObjectId; cost: number; qty: number }>([
|
|
||||||
{ $match: { businessId: businessObjId, productId: { $ne: null }, createdAt: { $lt: to } } },
|
|
||||||
{ $group: { _id: "$productId", cost: { $sum: "$totalCost" }, qty: { $sum: "$quantity" } } },
|
|
||||||
]),
|
|
||||||
Purchase.aggregate<{ total: number; count: number }>([
|
Purchase.aggregate<{ total: number; count: number }>([
|
||||||
{ $match: inPeriod },
|
{ $match: inPeriod },
|
||||||
{ $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } },
|
{ $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } },
|
||||||
@@ -84,19 +82,18 @@ export async function GET(req: NextRequest) {
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const avgCost = new Map(avgCosts.filter((c) => c.qty > 0).map((c) => [c._id.toString(), c.cost / c.qty]));
|
let revenue = 0, cashTotal = 0, mpesaTotal = 0, salesCount = 0, unitsSold = 0;
|
||||||
let revenue = 0, cashTotal = 0, mpesaTotal = 0, salesCount = 0, unitsSold = 0, unitsWithoutCost = 0, costOfGoodsSold = 0;
|
|
||||||
for (const s of salesByProduct) {
|
for (const s of salesByProduct) {
|
||||||
revenue += s.revenue;
|
revenue += s.revenue;
|
||||||
cashTotal += s.cashTotal;
|
cashTotal += s.cashTotal;
|
||||||
mpesaTotal += s.mpesaTotal;
|
mpesaTotal += s.mpesaTotal;
|
||||||
salesCount += s.lines;
|
salesCount += s.lines;
|
||||||
unitsSold += s.units;
|
unitsSold += s.units;
|
||||||
const unitCost = avgCost.get(s._id.toString());
|
|
||||||
if (unitCost === undefined) unitsWithoutCost += s.units;
|
|
||||||
else costOfGoodsSold += s.units * unitCost;
|
|
||||||
}
|
}
|
||||||
costOfGoodsSold = Math.round(costOfGoodsSold * 100) / 100;
|
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 stockPurchases = purchaseAgg[0]?.total ?? 0;
|
||||||
const expenses = expenseAgg[0]?.total ?? 0;
|
const expenses = expenseAgg[0]?.total ?? 0;
|
||||||
@@ -111,13 +108,15 @@ export async function GET(req: NextRequest) {
|
|||||||
salesCount,
|
salesCount,
|
||||||
unitsSold,
|
unitsSold,
|
||||||
costOfGoodsSold,
|
costOfGoodsSold,
|
||||||
// Units sold of products never bought through Purchases: their cost
|
costMethod: "FIFO per branch",
|
||||||
// is unknown, so gross profit overstates by their cost.
|
// Units sold whose cost is unknown (no purchase price for that stock on
|
||||||
|
// record): gross profit overstates by their cost.
|
||||||
unitsWithoutCost,
|
unitsWithoutCost,
|
||||||
|
stockLosses: cost.stockLosses,
|
||||||
grossProfit,
|
grossProfit,
|
||||||
expenses,
|
expenses,
|
||||||
expenseCount: expenseAgg[0]?.count ?? 0,
|
expenseCount: expenseAgg[0]?.count ?? 0,
|
||||||
netProfit: Math.round((grossProfit - expenses) * 100) / 100,
|
netProfit: Math.round((grossProfit - expenses - cost.stockLosses) * 100) / 100,
|
||||||
stockPurchases,
|
stockPurchases,
|
||||||
purchaseCount: purchaseAgg[0]?.count ?? 0,
|
purchaseCount: purchaseAgg[0]?.count ?? 0,
|
||||||
cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100,
|
cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const lockedBranch = branchId;
|
const lockedBranch = branchId;
|
||||||
try {
|
try {
|
||||||
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
|
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
|
||||||
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
|
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId, businessId: auth.businessId });
|
||||||
if (!already) {
|
if (!already) {
|
||||||
const before = await stockOf(auth.businessId, lockedBranch, data.productId);
|
const before = await stockOf(auth.businessId, lockedBranch, data.productId);
|
||||||
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
|
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { connectDb } from "@/lib/server/db";
|
import { connectDb } from "@/lib/server/db";
|
||||||
import { StockMovement } from "@/lib/server/models/StockMovement";
|
import { StockMovement } from "@/lib/server/models/StockMovement";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
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";
|
||||||
@@ -23,9 +24,9 @@ export async function GET(req: NextRequest) {
|
|||||||
const movements = await StockMovement.find(filter)
|
const movements = await StockMovement.find(filter)
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.populate("branchId", "branchName")
|
.populate(scopedPopulate("branchId", "branchName", auth.businessId))
|
||||||
.populate("productId", "label unit")
|
.populate(scopedPopulate("productId", "label unit", auth.businessId))
|
||||||
.populate("recordedBy", "name")
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
.lean();
|
.lean();
|
||||||
return NextResponse.json({ movements });
|
return NextResponse.json({ movements });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
|||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
|
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
|
||||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
import { objectId } from "@/lib/server/validation";
|
import { objectId } from "@/lib/server/validation";
|
||||||
import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
|
import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
|
||||||
|
|
||||||
@@ -27,10 +28,10 @@ export async function GET(req: NextRequest) {
|
|||||||
const transfers = await StockTransfer.find(filter)
|
const transfers = await StockTransfer.find(filter)
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.limit(100)
|
.limit(100)
|
||||||
.populate("fromBranchId", "branchName")
|
.populate(scopedPopulate("fromBranchId", "branchName", auth.businessId))
|
||||||
.populate("toBranchId", "branchName")
|
.populate(scopedPopulate("toBranchId", "branchName", auth.businessId))
|
||||||
.populate("productId", "label unit")
|
.populate(scopedPopulate("productId", "label unit", auth.businessId))
|
||||||
.populate("recordedBy", "name")
|
.populate(scopedPopulate("recordedBy", "name", auth.businessId))
|
||||||
.lean();
|
.lean();
|
||||||
return NextResponse.json({ transfers });
|
return NextResponse.json({ transfers });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,10 +167,8 @@ export async function POST(req: NextRequest) {
|
|||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if ((err as { code?: number })?.code !== 11000) throw err;
|
if ((err as { code?: number })?.code !== 11000) throw err;
|
||||||
// Already recorded by an earlier attempt: reuse it.
|
// Already recorded by an earlier attempt: reuse it.
|
||||||
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}` });
|
const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}`, businessId: auth.businessId });
|
||||||
if (!existing || existing.businessId.toString() !== auth.businessId) {
|
if (!existing) return reject("This sale id is already in use.");
|
||||||
return reject("This sale id is already in use.");
|
|
||||||
}
|
|
||||||
txn = existing;
|
txn = existing;
|
||||||
}
|
}
|
||||||
// Idempotent, and also repairs a line whose stock movement was not
|
// Idempotent, and also repairs a line whose stock movement was not
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 { authorize, branchParam } from "@/lib/server/auth";
|
import { authorize, branchParam } from "@/lib/server/auth";
|
||||||
|
import { scopedPopulate } from "@/lib/server/tenantGuard";
|
||||||
import { startOfTodayNairobi } from "@/lib/server/businessDay";
|
import { startOfTodayNairobi } from "@/lib/server/businessDay";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -26,7 +27,7 @@ export async function GET(req: NextRequest) {
|
|||||||
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 });
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { isValidObjectId } from "mongoose";
|
|||||||
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 { authorize, MANAGERS } from "@/lib/server/auth";
|
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||||
import { issueInvite } from "@/lib/server/invites";
|
import { invitePending, issueInvite } from "@/lib/server/invites";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/users/:id/invite — a new invite link for someone who has not
|
* POST /api/users/:id/invite — a one-time password link for a staff member:
|
||||||
* accepted theirs yet (lost or expired). The previous link stops working.
|
* - an INVITE link while they have not set a password yet (lost or expired invite);
|
||||||
* Owners: anyone in the business. Managers: their own branch's cashiers.
|
* - 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 } }) {
|
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
@@ -23,13 +26,17 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
|||||||
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
|
||||||
if (auth.role === "BRANCH_MANAGER" && !(user.role === "DELIVERY" && user.branchId?.toString() === auth.branchId)) {
|
if (auth.role === "BRANCH_MANAGER" && !(user.role === "DELIVERY" && user.branchId?.toString() === auth.branchId)) {
|
||||||
return NextResponse.json({ error: "Branch managers may only invite delivery staff in their own branch" }, { status: 403 });
|
return NextResponse.json({ error: "Branch managers may only do this for delivery staff in their own branch" }, { status: 403 });
|
||||||
}
|
}
|
||||||
if (!user.inviteExpiresAt) {
|
if (user._id.toString() === auth.sub) {
|
||||||
return NextResponse.json({ error: "This person has already set their password" }, { status: 409 });
|
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 inviteUrl = issueInvite(user, req);
|
const purpose = invitePending(user) ? "invite" : "reset";
|
||||||
|
const inviteUrl = issueInvite(user, req, purpose);
|
||||||
await user.save();
|
await user.save();
|
||||||
return NextResponse.json({ inviteUrl, expiresAt: user.inviteExpiresAt });
|
return NextResponse.json({ inviteUrl, purpose, expiresAt: user.inviteExpiresAt });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,14 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
|||||||
}
|
}
|
||||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
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 });
|
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||||
|
const fromBranch = target.branchId;
|
||||||
target.branchId = branch._id;
|
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;
|
if (isActive !== undefined) target.isActive = isActive;
|
||||||
await target.save();
|
await target.save();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
|||||||
import { objectId } from "@/lib/server/validation";
|
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 { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
|
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";
|
||||||
@@ -26,7 +26,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean();
|
const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean();
|
||||||
// invitePending: the person has not set their password yet.
|
// invitePending: the person has not set their password yet.
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
users: users.map((u) => ({ ...u, invitePending: !!u.inviteExpiresAt })),
|
users: users.map((u) => ({ ...u, invitePending: invitePending(u) })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface Invite {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
businessName: string | null;
|
businessName: string | null;
|
||||||
|
purpose: "invite" | "reset";
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
const inputClass =
|
||||||
@@ -60,9 +61,12 @@ export default function InvitePage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs uppercase tracking-wide text-flow-600">{invite.businessName ?? "Vessel"}</p>
|
<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">Welcome, {invite.name}</h1>
|
<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">
|
<p className="mt-2 text-sm text-depth-600">
|
||||||
Choose a password. You will sign in with <strong>{invite.email}</strong> and this password.
|
{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>
|
</p>
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+62
-3
@@ -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,7 +33,10 @@ 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 [invited, setInvited] = useState(false);
|
||||||
|
const [showForgot, setShowForgot] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInvited(new URLSearchParams(window.location.search).get("invited") === "1");
|
setInvited(new URLSearchParams(window.location.search).get("invited") === "1");
|
||||||
@@ -29,6 +44,10 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
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);
|
||||||
@@ -41,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) {
|
||||||
@@ -75,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">
|
||||||
@@ -176,9 +227,17 @@ 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 && (
|
{invited && !error && (
|
||||||
<p className="rounded-lg bg-confirm-500/10 px-3 py-2 text-sm text-confirm-600">
|
<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.
|
Your password is set. Sign in with your email and new password.
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,9 @@ export default function RootPage() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
|
const result = await apiFetch<{ user: AuthUser }>("/api/auth/me");
|
||||||
if (result.user.role === "DELIVERY") {
|
if (result.user.isPlatformAdmin) {
|
||||||
|
router.replace("/platform");
|
||||||
|
} else if (result.user.role === "DELIVERY") {
|
||||||
router.replace("/terminal");
|
router.replace("/terminal");
|
||||||
} else {
|
} else {
|
||||||
router.replace("/admin/dashboard");
|
router.replace("/admin/dashboard");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { apiFetch, ApiError } from "@/lib/api";
|
|||||||
import { AuthUser } from "@/lib/types";
|
import { AuthUser } from "@/lib/types";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { SkeletonList } from "@/components/ui/Skeleton";
|
import { SkeletonList } from "@/components/ui/Skeleton";
|
||||||
|
import AccountLinks from "@/components/AccountLinks";
|
||||||
|
|
||||||
type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
|
type TierId = "STARTER" | "GROWTH" | "ENTERPRISE";
|
||||||
type SubStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
type SubStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
||||||
@@ -63,6 +64,7 @@ export default function PlatformPage() {
|
|||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [ownerLink, setOwnerLink] = useState<{ business: string; name: string; email: string; url: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -107,6 +109,23 @@ export default function PlatformPage() {
|
|||||||
setSavingId(null);
|
setSavingId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A one-time password link for a business's owner (an owner who forgot
|
||||||
|
// their password has nobody inside the business who can reset it).
|
||||||
|
async function ownerReset(b: BizRow) {
|
||||||
|
if (!window.confirm(`Create a password link for the owner of ${b.name}? Send it only to the owner, by a channel you know is theirs.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await apiFetch<{ inviteUrl: string; email: string; name: string }>(
|
||||||
|
"/api/platform/businesses/" + b.id + "/owner-reset",
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
setOwnerLink({ business: b.name, name: r.name, email: r.email, url: r.inviteUrl });
|
||||||
|
} catch (err) {
|
||||||
|
show(err instanceof ApiError ? err.message : "Could not create the link", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-depth-950 pb-12">
|
<main className="min-h-screen bg-depth-950 pb-12">
|
||||||
@@ -130,6 +149,12 @@ export default function PlatformPage() {
|
|||||||
Every business on your platform, across all tenants. Change plans, extend billing periods,
|
Every business on your platform, across all tenants. Change plans, extend billing periods,
|
||||||
and suspend accounts here.
|
and suspend accounts here.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-3 flex items-center gap-4">
|
||||||
|
<a href="/admin/dashboard" className="text-sm text-sand-200/70 underline underline-offset-2 hover:text-white">
|
||||||
|
Your business dashboard
|
||||||
|
</a>
|
||||||
|
<AccountLinks />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -144,6 +169,28 @@ export default function PlatformPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ownerLink && (
|
||||||
|
<div className="rounded-xl2 border border-confirm-500/30 bg-confirm-500/10 p-4 text-sm">
|
||||||
|
<p className="font-medium text-white">
|
||||||
|
Password link for {ownerLink.name} ({ownerLink.email}), owner of {ownerLink.business}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-white/60">
|
||||||
|
It works once and expires within 24 hours (7 days if they never set a password). Whoever opens it can take
|
||||||
|
over the account: send it only to the owner, by a channel you know is theirs.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={ownerLink.url}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
aria-label="Owner password link"
|
||||||
|
className="mt-3 w-full rounded-lg border border-white/20 bg-depth-900 px-3 py-2 font-mono text-xs text-white"
|
||||||
|
/>
|
||||||
|
<button onClick={() => setOwnerLink(null)} className="mt-2 text-xs text-white/60 underline">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SkeletonList rows={4} />
|
<SkeletonList rows={4} />
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
@@ -158,6 +205,7 @@ export default function PlatformPage() {
|
|||||||
b={b}
|
b={b}
|
||||||
saving={savingId === b.id}
|
saving={savingId === b.id}
|
||||||
onPatch={patch}
|
onPatch={patch}
|
||||||
|
onOwnerReset={ownerReset}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,10 +228,12 @@ function BusinessCard({
|
|||||||
b,
|
b,
|
||||||
saving,
|
saving,
|
||||||
onPatch,
|
onPatch,
|
||||||
|
onOwnerReset,
|
||||||
}: {
|
}: {
|
||||||
b: BizRow;
|
b: BizRow;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
onPatch: (id: string, update: Record<string, unknown>, label: string) => void;
|
onPatch: (id: string, update: Record<string, unknown>, label: string) => void;
|
||||||
|
onOwnerReset: (b: BizRow) => void;
|
||||||
}) {
|
}) {
|
||||||
const overBranches = b.maxBranches !== null && b.branchCount > b.maxBranches;
|
const overBranches = b.maxBranches !== null && b.branchCount > b.maxBranches;
|
||||||
const overUsers = b.maxUsers !== null && b.userCount > b.maxUsers;
|
const overUsers = b.maxUsers !== null && b.userCount > b.maxUsers;
|
||||||
@@ -287,6 +337,15 @@ function BusinessCard({
|
|||||||
Reactivate
|
Reactivate
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{b.owner && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOwnerReset(b)}
|
||||||
|
className="tap-target rounded-xl2 border border-white/20 px-4 text-sm font-medium text-white/80"
|
||||||
|
>
|
||||||
|
Owner password link
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+11
-10
@@ -17,7 +17,8 @@ import {
|
|||||||
type QueuedSale,
|
type QueuedSale,
|
||||||
type QueueOwner,
|
type QueueOwner,
|
||||||
} from "@/lib/offlineQueue";
|
} from "@/lib/offlineQueue";
|
||||||
import { clearSession, loadSession, saveSession, type StockSnapshot } from "@/lib/sessionCache";
|
import { loadSession, saveSession, type StockSnapshot } from "@/lib/sessionCache";
|
||||||
|
import { signOut } from "@/lib/signOut";
|
||||||
import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol";
|
import { MAX_LINES_PER_SALE } from "@/lib/syncProtocol";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import ProductGrid from "@/components/terminal/ProductGrid";
|
import ProductGrid from "@/components/terminal/ProductGrid";
|
||||||
@@ -392,12 +393,7 @@ export default function TerminalPage() {
|
|||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
try {
|
await signOut();
|
||||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
|
||||||
} catch {
|
|
||||||
// Offline: the cookies expire on their own; still clear this device.
|
|
||||||
}
|
|
||||||
clearSession();
|
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,9 +447,14 @@ export default function TerminalPage() {
|
|||||||
>
|
>
|
||||||
{connected ? "● Online" : "● Offline"}
|
{connected ? "● Online" : "● Offline"}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
|
<div className="flex gap-3">
|
||||||
Sign out
|
<button onClick={() => router.push("/account/password")} className="text-xs text-sand-200/60 underline underline-offset-2">
|
||||||
</button>
|
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>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { signOut } from "@/lib/signOut";
|
||||||
|
|
||||||
|
/** Change password + Sign out, for the dark page headers. */
|
||||||
|
export default function AccountLinks() {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 whitespace-nowrap text-sm">
|
||||||
|
<Link href="/account/password" className="text-sand-200/70 underline underline-offset-2 hover:text-white">
|
||||||
|
Change password
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await signOut();
|
||||||
|
router.replace("/login");
|
||||||
|
}}
|
||||||
|
className="text-sand-200/70 underline underline-offset-2 hover:text-white"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ import type { NextRequest } from "next/server";
|
|||||||
import type { IUser } from "@/lib/server/models/User";
|
import type { IUser } from "@/lib/server/models/User";
|
||||||
|
|
||||||
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
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 {
|
export function hashInviteToken(token: string): string {
|
||||||
return crypto.createHash("sha256").update(token).digest("hex");
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
@@ -14,14 +18,20 @@ export async function unusablePasswordHash(): Promise<string> {
|
|||||||
return bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12);
|
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 invite (any earlier link stops working) and return
|
* Give the user a fresh one-time password link (any earlier link stops
|
||||||
* the link to send them. The caller saves the user.
|
* working) and return it to send them. The caller saves the user.
|
||||||
*/
|
*/
|
||||||
export function issueInvite(user: IUser, req: NextRequest): string {
|
export function issueInvite(user: IUser, req: NextRequest, purpose: LinkPurpose = "invite"): string {
|
||||||
const token = crypto.randomBytes(32).toString("base64url");
|
const token = crypto.randomBytes(32).toString("base64url");
|
||||||
user.inviteTokenHash = hashInviteToken(token);
|
user.inviteTokenHash = hashInviteToken(token);
|
||||||
user.inviteExpiresAt = new Date(Date.now() + INVITE_TTL_MS);
|
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;
|
const origin = process.env.APP_URL?.replace(/\/$/, "") || req.nextUrl.origin;
|
||||||
return `${origin}/invite/${token}`;
|
return `${origin}/invite/${token}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export interface IBranch extends Document {
|
export interface IBranch extends Document {
|
||||||
@@ -30,5 +31,7 @@ const branchSchema = new Schema<IBranch>(
|
|||||||
/** Filter for branches that are in use. */
|
/** Filter for branches that are in use. */
|
||||||
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
|
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
|
||||||
|
|
||||||
|
branchSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Branch =
|
export const Branch =
|
||||||
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export interface IDailyInventory extends Document {
|
export interface IDailyInventory extends Document {
|
||||||
@@ -40,6 +41,8 @@ dailyInventorySchema.index({ branchId: 1, date: 1 });
|
|||||||
// One ledger row per branch+product+day.
|
// One ledger row per branch+product+day.
|
||||||
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
|
dailyInventorySchema.index({ branchId: 1, productId: 1, date: 1 }, { unique: true });
|
||||||
|
|
||||||
|
dailyInventorySchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const DailyInventory =
|
export const DailyInventory =
|
||||||
(models.DailyInventory as Model<IDailyInventory>) ||
|
(models.DailyInventory as Model<IDailyInventory>) ||
|
||||||
model<IDailyInventory>("DailyInventory", dailyInventorySchema);
|
model<IDailyInventory>("DailyInventory", dailyInventorySchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
// Business expense categories. Kept as a constrained string set so the UI
|
// Business expense categories. Kept as a constrained string set so the UI
|
||||||
@@ -46,5 +47,7 @@ expenseSchema.index({ category: 1 });
|
|||||||
|
|
||||||
// `models.Expense` guard avoids OverwriteModelError on serverless warm
|
// `models.Expense` guard avoids OverwriteModelError on serverless warm
|
||||||
// re-invocations where the module is evaluated more than once.
|
// re-invocations where the module is evaluated more than once.
|
||||||
|
expenseSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Expense =
|
export const Expense =
|
||||||
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
|
(models.Expense as Model<IExpense>) || model<IExpense>("Expense", expenseSchema);
|
||||||
|
|||||||
@@ -1,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 IProduct extends Document {
|
export interface IProduct extends Document {
|
||||||
@@ -32,6 +33,8 @@ const productSchema = new Schema<IProduct>(
|
|||||||
{ 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);
|
||||||
|
|
||||||
@@ -57,6 +60,8 @@ const branchPricingSchema = new Schema<IBranchPricing>(
|
|||||||
);
|
);
|
||||||
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
|
branchPricingSchema.index({ branchId: 1, productId: 1 }, { unique: true });
|
||||||
|
|
||||||
|
branchPricingSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const BranchPricing =
|
export const BranchPricing =
|
||||||
(models.BranchPricing as Model<IBranchPricing>) ||
|
(models.BranchPricing as Model<IBranchPricing>) ||
|
||||||
model<IBranchPricing>("BranchPricing", branchPricingSchema);
|
model<IBranchPricing>("BranchPricing", branchPricingSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
// A recorded stock purchase from a supplier. Completing a purchase is what
|
// A recorded stock purchase from a supplier. Completing a purchase is what
|
||||||
@@ -40,5 +41,7 @@ const purchaseSchema = new Schema<IPurchase>(
|
|||||||
purchaseSchema.index({ branchId: 1, createdAt: -1 });
|
purchaseSchema.index({ branchId: 1, createdAt: -1 });
|
||||||
purchaseSchema.index({ productId: 1 });
|
purchaseSchema.index({ productId: 1 });
|
||||||
|
|
||||||
|
purchaseSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const Purchase =
|
export const Purchase =
|
||||||
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
|
(models.Purchase as Model<IPurchase>) || model<IPurchase>("Purchase", purchaseSchema);
|
||||||
|
|||||||
@@ -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 const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const;
|
export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const;
|
||||||
@@ -46,6 +47,8 @@ stockMovementSchema.index({ type: 1, refId: 1 }, { unique: true });
|
|||||||
stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 });
|
stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 });
|
||||||
stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 });
|
stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
stockMovementSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const StockMovement =
|
export const StockMovement =
|
||||||
(models.StockMovement as Model<IStockMovement>) ||
|
(models.StockMovement as Model<IStockMovement>) ||
|
||||||
model<IStockMovement>("StockMovement", stockMovementSchema);
|
model<IStockMovement>("StockMovement", stockMovementSchema);
|
||||||
|
|||||||
@@ -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 IStockTransfer extends Document {
|
export interface IStockTransfer extends Document {
|
||||||
@@ -35,6 +36,8 @@ const stockTransferSchema = new Schema<IStockTransfer>(
|
|||||||
stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true });
|
stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true });
|
||||||
stockTransferSchema.index({ businessId: 1, createdAt: -1 });
|
stockTransferSchema.index({ businessId: 1, createdAt: -1 });
|
||||||
|
|
||||||
|
stockTransferSchema.plugin(tenantGuard);
|
||||||
|
|
||||||
export const StockTransfer =
|
export const StockTransfer =
|
||||||
(models.StockTransfer as Model<IStockTransfer>) ||
|
(models.StockTransfer as Model<IStockTransfer>) ||
|
||||||
model<IStockTransfer>("StockTransfer", stockTransferSchema);
|
model<IStockTransfer>("StockTransfer", stockTransferSchema);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { tenantGuard } from "@/lib/server/tenantGuard";
|
||||||
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
import { Schema, model, models, Model, Types, Document } from "mongoose";
|
||||||
|
|
||||||
export type PaymentMethod = "CASH" | "MPESA";
|
export type PaymentMethod = "CASH" | "MPESA";
|
||||||
@@ -105,6 +106,8 @@ transactionSchema.index({ businessId: 1, createdAt: -1 });
|
|||||||
transactionSchema.index({ paymentStatus: 1 });
|
transactionSchema.index({ paymentStatus: 1 });
|
||||||
transactionSchema.index({ businessId: 1, saleId: 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>) ||
|
||||||
model<ITransaction>("Transaction", transactionSchema);
|
model<ITransaction>("Transaction", transactionSchema);
|
||||||
|
|||||||
@@ -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,17 +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;
|
||||||
// Pending invite: the person sets their own password through a one-time
|
// A one-time link through which the person sets their own password.
|
||||||
// link. Only the SHA-256 of the token is stored. Until they accept, the
|
// Only the SHA-256 of the token is stored.
|
||||||
// account has a random unknown password, so it cannot be signed into.
|
// 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;
|
inviteTokenHash: string | null;
|
||||||
inviteExpiresAt: Date | null;
|
inviteExpiresAt: Date | null;
|
||||||
|
invitePurpose: "invite" | "reset" | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@@ -33,18 +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 } },
|
inviteTokenHash: { type: String, default: null, index: { sparse: true } },
|
||||||
inviteExpiresAt: { type: Date, default: null },
|
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
|
||||||
@@ -64,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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
|||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { Purchase } from "@/lib/server/models/Purchase";
|
import { Purchase } from "@/lib/server/models/Purchase";
|
||||||
import { StockLock } from "@/lib/server/models/StockLock";
|
import { StockLock } from "@/lib/server/models/StockLock";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
import type { ITransaction } from "@/lib/server/models/Transaction";
|
import type { ITransaction } from "@/lib/server/models/Transaction";
|
||||||
|
|
||||||
type Id = Types.ObjectId | string;
|
type Id = Types.ObjectId | string;
|
||||||
@@ -26,7 +27,7 @@ export interface MovementInput {
|
|||||||
*/
|
*/
|
||||||
export async function recordMovement(m: MovementInput): Promise<void> {
|
export async function recordMovement(m: MovementInput): Promise<void> {
|
||||||
await StockMovement.updateOne(
|
await StockMovement.updateOne(
|
||||||
{ type: m.type, refId: m.refId },
|
{ type: m.type, refId: m.refId, businessId: m.businessId },
|
||||||
{
|
{
|
||||||
$setOnInsert: {
|
$setOnInsert: {
|
||||||
businessId: m.businessId,
|
businessId: m.businessId,
|
||||||
@@ -155,11 +156,14 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
|
|||||||
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
|
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
|
||||||
const filter: Record<string, unknown> = { productId: { $ne: null } };
|
const filter: Record<string, unknown> = { productId: { $ne: null } };
|
||||||
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
|
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;
|
let added = 0;
|
||||||
for await (const p of Purchase.find(filter).lean().cursor()) {
|
for (const p of purchases) {
|
||||||
const refId = p._id.toString();
|
const refId = p._id.toString();
|
||||||
if (await StockMovement.exists({ type: "PURCHASE", refId })) continue;
|
if (await StockMovement.exists({ type: "PURCHASE", refId, businessId: p.businessId })) continue;
|
||||||
await recordMovement({
|
await recordMovement({
|
||||||
businessId: p.businessId,
|
businessId: p.businessId,
|
||||||
branchId: p.branchId,
|
branchId: p.branchId,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { AsyncLocalStorage } from "async_hooks";
|
||||||
|
import { Types, type MongooseQueryMiddleware, type Schema } from "mongoose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant isolation, enforced instead of remembered.
|
||||||
|
*
|
||||||
|
* Every model that holds one business's data is guarded: a query against
|
||||||
|
* it that does not name exactly one business (`businessId: <id>`) throws
|
||||||
|
* TenantScopeError before it reaches the database. A route that forgets
|
||||||
|
* the business filter therefore fails loudly in development and in the
|
||||||
|
* test suite, instead of quietly reading or changing another business's
|
||||||
|
* data in production.
|
||||||
|
*
|
||||||
|
* The few queries that legitimately span businesses (login looking an
|
||||||
|
* email up everywhere, an invite token, the payment provider's callback,
|
||||||
|
* the platform console, maintenance scripts) must say so explicitly:
|
||||||
|
*
|
||||||
|
* await crossTenant("login looks the email up in every business", () => User.find(...));
|
||||||
|
*
|
||||||
|
* Guarded: find, findOne (and findById / exists), findOneAndUpdate /
|
||||||
|
* Delete / Replace, updateOne / updateMany, replaceOne, deleteOne /
|
||||||
|
* deleteMany, countDocuments, distinct, and aggregate (its first stage
|
||||||
|
* must be a $match naming the business). populate() runs a find on the
|
||||||
|
* referenced model, so populate through `scopedPopulate` below.
|
||||||
|
*
|
||||||
|
* Not guarded, by design: Business (the tenant itself, always looked up by
|
||||||
|
* the id from the session), UserSession (keyed by the signed-in user),
|
||||||
|
* MpesaCallbackLog (provider audit log), StockLock (key already contains
|
||||||
|
* the business). Document saves and inserts are covered by `businessId`
|
||||||
|
* being a required field.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class TenantScopeError extends Error {
|
||||||
|
constructor(model: string, op: string) {
|
||||||
|
super(
|
||||||
|
`Tenant isolation: ${model}.${op} must filter by a single businessId ` +
|
||||||
|
`(or run inside crossTenant("why", ...) if it really spans businesses).`
|
||||||
|
);
|
||||||
|
this.name = "TenantScopeError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossTenantReason = new AsyncLocalStorage<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn`, whose queries deliberately span businesses. `reason` documents
|
||||||
|
* why. The result is awaited inside the scope, so a lazy Mongoose query
|
||||||
|
* returned by `fn` executes while the exemption applies.
|
||||||
|
*/
|
||||||
|
export function crossTenant<T>(reason: string, fn: () => PromiseLike<T>): Promise<T> {
|
||||||
|
if (!reason.trim()) throw new Error("crossTenant needs a reason");
|
||||||
|
return crossTenantReason.run(reason, async () => await fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the filter pins exactly one business. */
|
||||||
|
export function namesOneBusiness(filter: unknown): boolean {
|
||||||
|
if (!filter || typeof filter !== "object") return false;
|
||||||
|
const f = filter as Record<string, unknown>;
|
||||||
|
const v = f.businessId;
|
||||||
|
if (typeof v === "string" || v instanceof Types.ObjectId) return true;
|
||||||
|
if (v && typeof v === "object" && "$eq" in (v as object)) {
|
||||||
|
const eq = (v as { $eq: unknown }).$eq;
|
||||||
|
return typeof eq === "string" || eq instanceof Types.ObjectId;
|
||||||
|
}
|
||||||
|
return Array.isArray(f.$and) && f.$and.some(namesOneBusiness);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_OPS: MongooseQueryMiddleware[] = [
|
||||||
|
"find",
|
||||||
|
"findOne",
|
||||||
|
"findOneAndUpdate",
|
||||||
|
"findOneAndDelete",
|
||||||
|
"findOneAndReplace",
|
||||||
|
"updateOne",
|
||||||
|
"updateMany",
|
||||||
|
"replaceOne",
|
||||||
|
"deleteOne",
|
||||||
|
"deleteMany",
|
||||||
|
"countDocuments",
|
||||||
|
"distinct",
|
||||||
|
];
|
||||||
|
|
||||||
|
const guardedSchemas = new WeakSet<Schema>();
|
||||||
|
|
||||||
|
/** Whether a schema has the guard (the test suite checks every business-scoped model does). */
|
||||||
|
export function isTenantGuarded(schema: Schema): boolean {
|
||||||
|
return guardedSchemas.has(schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mongoose plugin: apply to every schema with a `businessId` path. */
|
||||||
|
export function tenantGuard(schema: Schema): void {
|
||||||
|
guardedSchemas.add(schema);
|
||||||
|
|
||||||
|
schema.pre(QUERY_OPS, function (this: any) {
|
||||||
|
if (crossTenantReason.getStore()) return;
|
||||||
|
if (!namesOneBusiness(this.getFilter())) throw new TenantScopeError(this.model.modelName, this.op);
|
||||||
|
});
|
||||||
|
|
||||||
|
schema.pre("aggregate", function (this: any) {
|
||||||
|
if (crossTenantReason.getStore()) return;
|
||||||
|
const first = this.pipeline()[0];
|
||||||
|
if (!first || !namesOneBusiness(first.$match)) {
|
||||||
|
throw new TenantScopeError(this._model?.modelName ?? "Model", "aggregate");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* populate() options that keep the referenced documents inside the
|
||||||
|
* business too (populate queries the referenced model, which is guarded).
|
||||||
|
*/
|
||||||
|
export function scopedPopulate(path: string, select: string, businessId: string | Types.ObjectId) {
|
||||||
|
return { path, select, match: { businessId } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,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();
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import * as login from "@/app/api/auth/login/route";
|
|||||||
import * as signup from "@/app/api/auth/signup/route";
|
import * as signup from "@/app/api/auth/signup/route";
|
||||||
import * as stkpush from "@/app/api/mpesa/stkpush/route";
|
import * as stkpush from "@/app/api/mpesa/stkpush/route";
|
||||||
import * as callback from "@/app/api/mpesa/callback/route";
|
import * as callback from "@/app/api/mpesa/callback/route";
|
||||||
import { json, makeTenant, params, req, uuid } from "./helpers";
|
import { json, makeTenant, params, req, uuid, db } from "./helpers";
|
||||||
|
|
||||||
describe("role-based access on the backend", () => {
|
describe("role-based access on the backend", () => {
|
||||||
it("blocks cashiers from owner-level money endpoints with 403", async () => {
|
it("blocks cashiers from owner-level money endpoints with 403", async () => {
|
||||||
@@ -78,7 +78,7 @@ describe("user deactivation", () => {
|
|||||||
params({ id: t.cashierA.id })
|
params({ id: t.cashierA.id })
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect((await User.findById(t.cashierA.id))!.isActive).toBe(false);
|
expect((await db(() => User.findById(t.cashierA.id)))!.isActive).toBe(false);
|
||||||
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
|
expect(await UserSession.countDocuments({ userId: t.cashierA.id })).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ describe("tenant status", () => {
|
|||||||
it("refuses login for a suspended business", async () => {
|
it("refuses login for a suspended business", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||||
await User.updateOne({ _id: t.cashierA.id }, { passwordHash });
|
await db(() => User.updateOne({ _id: t.cashierA.id }, { passwordHash }));
|
||||||
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
|
await Business.updateOne({ _id: t.businessId }, { subscriptionStatus: "SUSPENDED" });
|
||||||
|
|
||||||
const res = await login.POST(
|
const res = await login.POST(
|
||||||
@@ -119,7 +119,7 @@ describe("tenant status", () => {
|
|||||||
it("still allows login for an active business", async () => {
|
it("still allows login for an active business", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
const passwordHash = await bcrypt.hash("correct-horse", 4);
|
||||||
await User.updateOne({ _id: t.managerA.id }, { passwordHash });
|
await db(() => User.updateOne({ _id: t.managerA.id }, { passwordHash }));
|
||||||
const res = await login.POST(
|
const res = await login.POST(
|
||||||
req("POST", "/api/auth/login", {
|
req("POST", "/api/auth/login", {
|
||||||
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
|
body: { email: t.managerA.email, password: "correct-horse", deviceId: uuid() },
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { BranchPricing } from "@/lib/server/models/Product";
|
|||||||
import * as prices from "@/app/api/branch-prices/route";
|
import * as prices from "@/app/api/branch-prices/route";
|
||||||
import * as products from "@/app/api/products/route";
|
import * as products from "@/app/api/products/route";
|
||||||
import * as cash from "@/app/api/transactions/cash/route";
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
|
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
const setPrice = (as: Actor, branchId: string, productId: string, unitPrice: number) =>
|
const setPrice = (as: Actor, branchId: string, productId: string, unitPrice: number) =>
|
||||||
prices.PUT(req("PUT", "/api/branch-prices", { as, body: { branchId, productId, unitPrice } }));
|
prices.PUT(req("PUT", "/api/branch-prices", { as, body: { branchId, productId, unitPrice } }));
|
||||||
@@ -44,7 +44,7 @@ describe("branch prices", () => {
|
|||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await setPrice(t.owner, t.branchA, t.water20, 180);
|
await setPrice(t.owner, t.branchA, t.water20, 180);
|
||||||
await setPrice(t.owner, t.branchA, t.water20, 200);
|
await setPrice(t.owner, t.branchA, t.water20, 200);
|
||||||
expect(await BranchPricing.countDocuments({})).toBe(0);
|
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
|
||||||
|
|
||||||
await setPrice(t.owner, t.branchA, t.water5, 55);
|
await setPrice(t.owner, t.branchA, t.water5, 55);
|
||||||
const reset = await prices.DELETE(
|
const reset = await prices.DELETE(
|
||||||
@@ -85,6 +85,6 @@ describe("branch prices", () => {
|
|||||||
expect((await setPrice(t.owner, other.branchA, t.water20, 150)).status).toBe(404);
|
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 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 prices.GET(req("GET", "/api/branch-prices?branchId=" + other.branchA, { as: t.owner }))).status).toBe(404);
|
||||||
expect(await BranchPricing.countDocuments({})).toBe(0);
|
expect(await db(() => BranchPricing.countDocuments({}))).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { StockTransfer } from "@/lib/server/models/StockTransfer";
|
|||||||
import { StockLock } from "@/lib/server/models/StockLock";
|
import { StockLock } from "@/lib/server/models/StockLock";
|
||||||
import * as adjustments from "@/app/api/stock/adjustments/route";
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||||
import * as transfers from "@/app/api/stock/transfers/route";
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
import { makeTenant, req, uuid, type Tenant } from "./helpers";
|
import { makeTenant, req, uuid, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
async function openingStock(t: Tenant, quantity: number) {
|
async function openingStock(t: Tenant, quantity: number) {
|
||||||
await adjustments.POST(
|
await adjustments.POST(
|
||||||
@@ -35,7 +35,7 @@ describe("concurrent stock changes", () => {
|
|||||||
expect(statuses.filter((s) => s === 409)).toHaveLength(3);
|
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.branchA, t.water20)).toBe(2);
|
||||||
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(8);
|
expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(8);
|
||||||
expect(await StockTransfer.countDocuments({})).toBe(2);
|
expect(await db(() => StockTransfer.countDocuments({}))).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("holds across repeated races, not just once", async () => {
|
it("holds across repeated races, not just once", async () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+11
-11
@@ -19,7 +19,7 @@ import * as profit from "@/app/api/reports/profit/route";
|
|||||||
import * as cash from "@/app/api/transactions/cash/route";
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
import * as adjustments from "@/app/api/stock/adjustments/route";
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||||
import * as transfers from "@/app/api/stock/transfers/route";
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
import { json, makeTenant, params, req, uuid, type Tenant } from "./helpers";
|
import { json, makeTenant, params, req, uuid, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
|
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
|
||||||
await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus });
|
await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus });
|
||||||
@@ -39,7 +39,7 @@ describe("F-01 trial branch allowance", () => {
|
|||||||
it("counts only active branches, and a paid Starter plan allows one", async () => {
|
it("counts only active branches, and a paid Starter plan allows one", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await setPlan(t, "STARTER", "TRIALING");
|
await setPlan(t, "STARTER", "TRIALING");
|
||||||
await Branch.updateOne({ _id: t.branchB }, { isActive: false });
|
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
|
||||||
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201);
|
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201);
|
||||||
|
|
||||||
await setPlan(t, "STARTER", "ACTIVE");
|
await setPlan(t, "STARTER", "ACTIVE");
|
||||||
@@ -70,7 +70,7 @@ describe("F-06 branch deactivation", () => {
|
|||||||
expect(withStaff.status).toBe(409);
|
expect(withStaff.status).toBe(409);
|
||||||
expect((await withStaff.json()).error).toMatch(/active staff/);
|
expect((await withStaff.json()).error).toMatch(/active staff/);
|
||||||
|
|
||||||
await User.updateOne({ _id: t.cashierB.id }, { isActive: false });
|
await db(() => User.updateOne({ _id: t.cashierB.id }, { isActive: false }));
|
||||||
expect((await deactivate()).status).toBe(200);
|
expect((await deactivate()).status).toBe(200);
|
||||||
|
|
||||||
// Hidden from pickers, kept on the Branches page.
|
// Hidden from pickers, kept on the Branches page.
|
||||||
@@ -82,7 +82,7 @@ describe("F-06 branch deactivation", () => {
|
|||||||
|
|
||||||
it("will not reactivate a branch past the plan's limit", async () => {
|
it("will not reactivate a branch past the plan's limit", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await Branch.updateOne({ _id: t.branchB }, { isActive: false });
|
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
|
||||||
await setPlan(t, "STARTER", "ACTIVE");
|
await setPlan(t, "STARTER", "ACTIVE");
|
||||||
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
|
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
@@ -100,12 +100,12 @@ describe("F-03 report period validation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("F-07 cost of goods sold", () => {
|
describe("F-07 cost of goods sold", () => {
|
||||||
it("costs only the units sold, at the average buying price, and reports purchases separately", async () => {
|
it("costs only the units sold, first-in-first-out, and reports purchases separately", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
const buy = (quantity: number, unitCost: number) =>
|
const buy = (quantity: number, unitCost: number) =>
|
||||||
purchases.POST(req("POST", "/x", { as: t.managerA, body: { supplierName: "Kiwanja", productId: t.water20, quantity, unitCost } }));
|
purchases.POST(req("POST", "/x", { as: t.managerA, body: { supplierName: "Kiwanja", productId: t.water20, quantity, unitCost } }));
|
||||||
await buy(20, 10);
|
await buy(20, 10);
|
||||||
await buy(10, 13); // average (200 + 130) / 30 = 11
|
await buy(10, 13);
|
||||||
await cash.POST(
|
await cash.POST(
|
||||||
req("POST", "/x", {
|
req("POST", "/x", {
|
||||||
as: t.cashierA,
|
as: t.cashierA,
|
||||||
@@ -116,9 +116,9 @@ describe("F-07 cost of goods sold", () => {
|
|||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
revenue: 660, // 3 x 200 + 1 x 60
|
revenue: 660, // 3 x 200 + 1 x 60
|
||||||
unitsSold: 4,
|
unitsSold: 4,
|
||||||
costOfGoodsSold: 33, // 3 x 11; the 5L was never purchased
|
costOfGoodsSold: 30, // 3 x 10 from the oldest purchase; the 5L was never purchased
|
||||||
unitsWithoutCost: 1,
|
unitsWithoutCost: 1,
|
||||||
grossProfit: 627,
|
grossProfit: 630,
|
||||||
stockPurchases: 330,
|
stockPurchases: 330,
|
||||||
cashSurplus: 330,
|
cashSurplus: 330,
|
||||||
});
|
});
|
||||||
@@ -152,7 +152,7 @@ describe("F-04 / F-05 / CAT-12 catalogue", () => {
|
|||||||
await adjustments.POST(
|
await adjustments.POST(
|
||||||
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } })
|
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } })
|
||||||
);
|
);
|
||||||
await Product.updateOne({ _id: t.water5 }, { isActive: false });
|
await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
|
||||||
|
|
||||||
const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
|
const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
|
||||||
const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA })));
|
const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA })));
|
||||||
@@ -228,13 +228,13 @@ describe("STA-04 staff invites", () => {
|
|||||||
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
|
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
|
||||||
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(200);
|
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(200);
|
||||||
|
|
||||||
await User.updateOne({ _id: new mongoose.Types.ObjectId(userId) }, { inviteExpiresAt: new Date(Date.now() - 1000) });
|
await db(() => User.updateOne({ _id: new mongoose.Types.ObjectId(userId) }, { inviteExpiresAt: new Date(Date.now() - 1000) }));
|
||||||
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(404);
|
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not let a manager re-invite another branch's staff", async () => {
|
it("does not let a manager re-invite another branch's staff", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) });
|
await db(() => User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) }));
|
||||||
const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id }));
|
const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id }));
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { User, UserRole } from "@/lib/server/models/User";
|
|||||||
import { Product } from "@/lib/server/models/Product";
|
import { Product } from "@/lib/server/models/Product";
|
||||||
import { signAccessToken } from "@/lib/server/jwt";
|
import { signAccessToken } from "@/lib/server/jwt";
|
||||||
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
||||||
|
import { crossTenant } from "@/lib/server/tenantGuard";
|
||||||
|
|
||||||
let phoneSeq = 0;
|
let phoneSeq = 0;
|
||||||
function nextPhone(): string {
|
function nextPhone(): string {
|
||||||
@@ -131,3 +132,8 @@ export async function json<T = any>(res: Response): Promise<{ status: number; bo
|
|||||||
export function uuid(): string {
|
export function uuid(): string {
|
||||||
return randomUUID();
|
return randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tests may inspect and arrange any business's data directly. */
|
||||||
|
export function db<T>(fn: () => PromiseLike<T>): Promise<T> {
|
||||||
|
return crossTenant("test inspects the database directly", fn);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import * as users from "@/app/api/users/route";
|
||||||
|
import * as login from "@/app/api/auth/login/route";
|
||||||
|
import * as me from "@/app/api/auth/me/route";
|
||||||
|
import * as platformBusinesses from "@/app/api/platform/businesses/route";
|
||||||
|
import { ACCESS_COOKIE } from "@/lib/server/auth";
|
||||||
|
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
|
const PERSON = { name: "Wanjiru", email: "wanjiru@aquaflow.test", phoneNumber: "0722000111" };
|
||||||
|
const OPERATOR = "operator@aquaflow.test"; // listed in PLATFORM_ADMIN_EMAILS by tests/setup.ts
|
||||||
|
|
||||||
|
async function hire(t: Tenant, as: Actor, person = PERSON) {
|
||||||
|
return users.POST(
|
||||||
|
req("POST", "/api/users", { as, body: { ...person, role: "DELIVERY", branchId: t.branchA, password: "password-123" } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signIn = (email: string, password: string, businessId?: string) =>
|
||||||
|
login.POST(req("POST", "/api/auth/login", { body: { email, password, deviceId: uuid(), businessId } }));
|
||||||
|
|
||||||
|
async function setPassword(email: string, businessId: string, password: string) {
|
||||||
|
await User.updateOne({ email, businessId }, { passwordHash: await bcrypt.hash(password, 4) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessCookie(res: Response): string {
|
||||||
|
const cookie = res.headers.get("set-cookie") ?? "";
|
||||||
|
return cookie.match(new RegExp(`${ACCESS_COOKIE}=([^;]+)`))![1];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("email and phone are unique per business", () => {
|
||||||
|
it("lets one person work for two businesses with the same email and phone", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
const b = await makeTenant("Beta");
|
||||||
|
expect((await hire(a, a.owner)).status).toBe(201);
|
||||||
|
expect((await hire(b, b.owner)).status).toBe(201);
|
||||||
|
expect(await db(() => User.countDocuments({ email: PERSON.email }))).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses a duplicate inside the same business", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
await hire(a, a.owner);
|
||||||
|
const sameEmail = await hire(a, a.owner, { ...PERSON, phoneNumber: "0733000222" });
|
||||||
|
const samePhone = await hire(a, a.owner, { ...PERSON, email: "other@aquaflow.test" });
|
||||||
|
expect([sameEmail.status, samePhone.status]).toEqual([409, 409]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("login when an email exists in several businesses", () => {
|
||||||
|
it("signs straight in when only one account's password matches", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
const b = await makeTenant("Beta");
|
||||||
|
await hire(a, a.owner);
|
||||||
|
await hire(b, b.owner);
|
||||||
|
await setPassword(PERSON.email, a.businessId, "alpha-secret");
|
||||||
|
await setPassword(PERSON.email, b.businessId, "beta-secret");
|
||||||
|
|
||||||
|
const res = await json(await signIn(PERSON.email, "beta-secret"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user.businessId).toBe(b.businessId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks which business when the same password opens both, then signs in to the one chosen", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
const b = await makeTenant("Beta");
|
||||||
|
await hire(a, a.owner);
|
||||||
|
await hire(b, b.owner);
|
||||||
|
await setPassword(PERSON.email, a.businessId, "same-secret");
|
||||||
|
await setPassword(PERSON.email, b.businessId, "same-secret");
|
||||||
|
|
||||||
|
const ask = await json(await signIn(PERSON.email, "same-secret"));
|
||||||
|
expect(ask.status).toBe(300);
|
||||||
|
expect(ask.body.chooseBusiness.map((c: any) => c.businessName).sort()).toEqual(["Alpha", "Beta"]);
|
||||||
|
|
||||||
|
const chosen = await json(await signIn(PERSON.email, "same-secret", a.businessId));
|
||||||
|
expect(chosen.status).toBe(200);
|
||||||
|
expect(chosen.body.user.businessId).toBe(a.businessId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals nothing about other businesses to a wrong password", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
const b = await makeTenant("Beta");
|
||||||
|
await hire(a, a.owner);
|
||||||
|
await hire(b, b.owner);
|
||||||
|
const res = await json(await signIn(PERSON.email, "wrong-password"));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.chooseBusiness).toBeUndefined();
|
||||||
|
expect(res.body.error).toBe("Incorrect email or password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a wrong guess at one business unlock or reset the other", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
const b = await makeTenant("Beta");
|
||||||
|
await hire(a, a.owner);
|
||||||
|
await hire(b, b.owner);
|
||||||
|
await setPassword(PERSON.email, a.businessId, "alpha-secret");
|
||||||
|
await setPassword(PERSON.email, b.businessId, "beta-secret");
|
||||||
|
for (let i = 0; i < 2; i++) await signIn(PERSON.email, "wrong");
|
||||||
|
await signIn(PERSON.email, "alpha-secret"); // resets Alpha's counter only
|
||||||
|
const [alpha, beta] = await Promise.all([
|
||||||
|
User.findOne({ email: PERSON.email, businessId: a.businessId }),
|
||||||
|
User.findOne({ email: PERSON.email, businessId: b.businessId }),
|
||||||
|
]);
|
||||||
|
expect([alpha!.failedLoginAttempts, beta!.failedLoginAttempts]).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("platform operator access", () => {
|
||||||
|
it("is not granted by an email match alone: another business cannot mint an operator", async () => {
|
||||||
|
const mallory = await makeTenant("Mallory Water");
|
||||||
|
await hire(mallory, mallory.owner, { name: "Fake operator", email: OPERATOR, phoneNumber: "0744000333" });
|
||||||
|
await setPassword(OPERATOR, mallory.businessId, "i-am-the-operator");
|
||||||
|
|
||||||
|
const res = await signIn(OPERATOR, "i-am-the-operator");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await res.clone().json()).isPlatformAdmin).toBe(false);
|
||||||
|
|
||||||
|
const consoleRes = await platformBusinesses.GET(
|
||||||
|
req("GET", "/api/platform/businesses", { cookies: { [ACCESS_COOKIE]: accessCookie(res) } })
|
||||||
|
);
|
||||||
|
expect(consoleRes.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is granted to the flagged account whose email is listed", async () => {
|
||||||
|
const home = await makeTenant("Operator Co");
|
||||||
|
await hire(home, home.owner, { name: "Operator", email: OPERATOR, phoneNumber: "0755000444" });
|
||||||
|
await User.updateOne({ email: OPERATOR, businessId: home.businessId }, { platformAdmin: true });
|
||||||
|
await setPassword(OPERATOR, home.businessId, "real-operator");
|
||||||
|
|
||||||
|
const res = await signIn(OPERATOR, "real-operator");
|
||||||
|
const cookies = { [ACCESS_COOKIE]: accessCookie(res) };
|
||||||
|
expect((await platformBusinesses.GET(req("GET", "/x", { cookies }))).status).toBe(200);
|
||||||
|
expect((await json(await me.GET(req("GET", "/x", { cookies })))).body.user.isPlatformAdmin).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is revoked by removing the email from PLATFORM_ADMIN_EMAILS, even with the flag", async () => {
|
||||||
|
const home = await makeTenant("Operator Co");
|
||||||
|
await hire(home, home.owner, { name: "Operator", email: OPERATOR, phoneNumber: "0755000444" });
|
||||||
|
await User.updateOne({ email: OPERATOR, businessId: home.businessId }, { platformAdmin: true });
|
||||||
|
await setPassword(OPERATOR, home.businessId, "real-operator");
|
||||||
|
const cookies = { [ACCESS_COOKIE]: accessCookie(await signIn(OPERATOR, "real-operator")) };
|
||||||
|
|
||||||
|
const listed = process.env.PLATFORM_ADMIN_EMAILS;
|
||||||
|
process.env.PLATFORM_ADMIN_EMAILS = "";
|
||||||
|
try {
|
||||||
|
expect((await platformBusinesses.GET(req("GET", "/x", { cookies }))).status).toBe(403);
|
||||||
|
} finally {
|
||||||
|
process.env.PLATFORM_ADMIN_EMAILS = listed;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { readdirSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { crossTenant, isTenantGuarded, TenantScopeError } from "@/lib/server/tenantGuard";
|
||||||
|
import { User } from "@/lib/server/models/User";
|
||||||
|
import { Transaction } from "@/lib/server/models/Transaction";
|
||||||
|
import { Expense } from "@/lib/server/models/Expense";
|
||||||
|
import { StockMovement } from "@/lib/server/models/StockMovement";
|
||||||
|
import { stockOf } from "@/lib/server/stock";
|
||||||
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
|
import * as transactions from "@/app/api/transactions/route";
|
||||||
|
import * as expenses from "@/app/api/expenses/route";
|
||||||
|
import * as purchases from "@/app/api/purchases/route";
|
||||||
|
import * as stock from "@/app/api/stock/route";
|
||||||
|
import * as movements from "@/app/api/stock/movements/route";
|
||||||
|
import * as transfers from "@/app/api/stock/transfers/route";
|
||||||
|
import * as adjustments from "@/app/api/stock/adjustments/route";
|
||||||
|
import * as summary from "@/app/api/dashboard/summary/route";
|
||||||
|
import * as reconciliation from "@/app/api/dashboard/reconciliation/route";
|
||||||
|
import * as profit from "@/app/api/reports/profit/route";
|
||||||
|
import * as users from "@/app/api/users/route";
|
||||||
|
import * as userById from "@/app/api/users/[id]/route";
|
||||||
|
import * as reinvite from "@/app/api/users/[id]/invite/route";
|
||||||
|
import * as branchById from "@/app/api/branches/[id]/route";
|
||||||
|
import * as productById from "@/app/api/products/[id]/route";
|
||||||
|
import * as branchPrices from "@/app/api/branch-prices/route";
|
||||||
|
import * as dispatch from "@/app/api/inventory/dispatch/route";
|
||||||
|
import * as eveningCount from "@/app/api/inventory/evening-count/route";
|
||||||
|
import { db, json, makeTenant, params, req, uuid, type Tenant } from "./helpers";
|
||||||
|
|
||||||
|
// Models that hold one business's data but are deliberately unguarded (see tenantGuard.ts).
|
||||||
|
const UNGUARDED = new Set(["Business", "UserSession", "MpesaCallbackLog", "StockLock"]);
|
||||||
|
|
||||||
|
describe("the tenant guard", () => {
|
||||||
|
it("is on every model that stores a businessId", async () => {
|
||||||
|
// Load every model file so none can be skipped by accident.
|
||||||
|
const dir = path.resolve(__dirname, "../src/lib/server/models");
|
||||||
|
for (const file of readdirSync(dir)) await import(path.join(dir, file));
|
||||||
|
|
||||||
|
const scoped = Object.values(mongoose.models).filter((m) => m.schema.path("businessId") && !UNGUARDED.has(m.modelName));
|
||||||
|
expect(scoped.length).toBeGreaterThanOrEqual(10);
|
||||||
|
const unguarded = scoped.filter((m) => !isTenantGuarded(m.schema)).map((m) => m.modelName);
|
||||||
|
expect(unguarded).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses queries that do not name exactly one business", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
const attempts: [string, () => PromiseLike<unknown>][] = [
|
||||||
|
["find", () => Transaction.find({ branchId: t.branchA })],
|
||||||
|
["findById", () => User.findById(t.cashierA.id)],
|
||||||
|
["exists", () => StockMovement.exists({ type: "SALE" })],
|
||||||
|
["updateMany", () => Expense.updateMany({}, { amount: 0 })],
|
||||||
|
["deleteMany", () => Transaction.deleteMany({ paymentMethod: "CASH" })],
|
||||||
|
["countDocuments", () => User.countDocuments({})],
|
||||||
|
["$in over businesses", () => User.find({ businessId: { $in: [t.businessId, new mongoose.Types.ObjectId()] } })],
|
||||||
|
["$exists", () => User.find({ businessId: { $exists: true } })],
|
||||||
|
["aggregate without $match", () => Transaction.aggregate([{ $group: { _id: "$businessId" } }])],
|
||||||
|
["aggregate matching later", () => Transaction.aggregate([{ $sort: { createdAt: -1 } }, { $match: { businessId: t.businessId } }])],
|
||||||
|
["unscoped populate", () => Expense.find({ businessId: t.businessId }).populate("branchId")],
|
||||||
|
];
|
||||||
|
await Expense.create({ businessId: t.businessId, branchId: t.branchA, category: "Rent", amount: 1, description: "x", recordedBy: t.owner.id });
|
||||||
|
for (const [label, run] of attempts) {
|
||||||
|
await expect(Promise.resolve().then(run), label).rejects.toBeInstanceOf(TenantScopeError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows scoped queries, and cross-business ones only with a stated reason", async () => {
|
||||||
|
const t = await makeTenant();
|
||||||
|
expect(await User.countDocuments({ businessId: t.businessId })).toBe(4);
|
||||||
|
expect(await User.countDocuments({ $and: [{ businessId: t.businessId }, { role: "DELIVERY" }] })).toBe(2);
|
||||||
|
expect(await crossTenant("test", () => User.countDocuments({}))).toBe(4);
|
||||||
|
expect(() => crossTenant(" ", () => User.countDocuments({}))).toThrow(/reason/);
|
||||||
|
// The exemption ends with the callback.
|
||||||
|
await expect(Promise.resolve().then(() => User.countDocuments({}))).rejects.toBeInstanceOf(TenantScopeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Give tenant B one of everything, so a leak into tenant A's view would show. */
|
||||||
|
async function seedVictim(b: Tenant) {
|
||||||
|
await adjustments.POST(
|
||||||
|
req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "DELTA", quantity: 50 } })
|
||||||
|
);
|
||||||
|
await cash.POST(
|
||||||
|
req("POST", "/x", { as: b.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 3 }] }] } })
|
||||||
|
);
|
||||||
|
await expenses.POST(req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, category: "Rent", amount: 7777, description: "B rent" } }));
|
||||||
|
await purchases.POST(
|
||||||
|
req("POST", "/x", { as: b.owner, body: { branchId: b.branchA, supplierName: "B supplier", productId: b.water20, quantity: 5, unitCost: 99 } })
|
||||||
|
);
|
||||||
|
await transfers.POST(
|
||||||
|
req("POST", "/x", { as: b.owner, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: b.branchB, productId: b.water20, quantity: 2 } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("route sweep: another business's ids get nothing", () => {
|
||||||
|
it("every read route returns none of business B's data to business A, whatever ids A sends", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
const b = await makeTenant("Beta");
|
||||||
|
await seedVictim(b);
|
||||||
|
|
||||||
|
const reads: [string, Response][] = [];
|
||||||
|
const get = async (label: string, handler: (r: any) => Promise<Response>, url: string) =>
|
||||||
|
reads.push([label, await handler(req("GET", url, { as: a.owner }))]);
|
||||||
|
const q = "?branchId=" + b.branchA;
|
||||||
|
await get("transactions", transactions.GET, "/api/transactions" + q);
|
||||||
|
await get("expenses", expenses.GET, "/api/expenses" + q);
|
||||||
|
await get("purchases", purchases.GET, "/api/purchases" + q);
|
||||||
|
await get("stock", stock.GET, "/api/stock" + q);
|
||||||
|
await get("movements", movements.GET, "/api/stock/movements" + q);
|
||||||
|
await get("transfers", transfers.GET, "/api/stock/transfers" + q);
|
||||||
|
await get("summary", summary.GET, "/api/dashboard/summary" + q);
|
||||||
|
await get("reconciliation", reconciliation.GET, "/api/dashboard/reconciliation" + q);
|
||||||
|
await get("profit", profit.GET, "/api/reports/profit" + q);
|
||||||
|
await get("users", users.GET, "/api/users" + q);
|
||||||
|
await get("branch prices", branchPrices.GET, "/api/branch-prices" + q);
|
||||||
|
|
||||||
|
const bIds = [b.businessId, b.branchA, b.branchB, b.water20, b.owner.id, b.cashierA.id];
|
||||||
|
for (const [label, res] of reads) {
|
||||||
|
const text = await res.text();
|
||||||
|
for (const id of bIds) expect(text.includes(id), `${label} leaked ${id}`).toBe(false);
|
||||||
|
expect(text.includes("7777") || text.includes("B supplier"), `${label} leaked B's figures`).toBe(false);
|
||||||
|
}
|
||||||
|
// Totals A sees are A's own (zero), not B's sale.
|
||||||
|
const totals = JSON.parse(await (await summary.GET(req("GET", "/api/dashboard/summary" + q, { as: a.owner }))).text());
|
||||||
|
expect(totals.totals.cashTotal).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every write route refuses business B's ids and leaves B's data untouched", async () => {
|
||||||
|
const a = await makeTenant("Alpha");
|
||||||
|
const b = await makeTenant("Beta");
|
||||||
|
await seedVictim(b);
|
||||||
|
const before = {
|
||||||
|
stockA: await stockOf(b.businessId, b.branchA, b.water20),
|
||||||
|
cashier: await db(() => User.findById(b.cashierA.id).lean()),
|
||||||
|
sales: await Transaction.countDocuments({ businessId: b.businessId }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const as = a.owner;
|
||||||
|
const writes: [string, Response][] = [
|
||||||
|
["deactivate B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { isActive: false } }), params({ id: b.cashierA.id }))],
|
||||||
|
["move B's cashier", await userById.PATCH(req("PATCH", "/x", { as, body: { branchId: a.branchA } }), params({ id: b.cashierA.id }))],
|
||||||
|
["re-invite B's cashier", await reinvite.POST(req("POST", "/x", { as }), params({ id: b.cashierA.id }))],
|
||||||
|
["rename B's branch", await branchById.PATCH(req("PATCH", "/x", { as, body: { branchName: "Pwned" } }), params({ id: b.branchA }))],
|
||||||
|
["reprice B's product", await productById.PATCH(req("PATCH", "/x", { as, body: { unitPrice: 1 } }), params({ id: b.water20 }))],
|
||||||
|
["branch price on B", await branchPrices.PUT(req("PUT", "/x", { as, body: { branchId: b.branchA, productId: b.water20, unitPrice: 1 } }))],
|
||||||
|
["adjust B's stock", await adjustments.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), branchId: b.branchA, productId: b.water20, mode: "COUNT", quantity: 0 } }))],
|
||||||
|
["transfer out of B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: b.branchA, toBranchId: a.branchA, productId: b.water20, quantity: 1 } }))],
|
||||||
|
["transfer into B", await transfers.POST(req("POST", "/x", { as, body: { clientUuid: uuid(), fromBranchId: a.branchA, toBranchId: b.branchA, productId: a.water20, quantity: 1 } }))],
|
||||||
|
["expense on B", await expenses.POST(req("POST", "/x", { as, body: { branchId: b.branchA, category: "Rent", amount: 1, description: "x" } }))],
|
||||||
|
["purchase into B", await purchases.POST(req("POST", "/x", { as, body: { branchId: b.branchA, supplierName: "x", productId: b.water20, quantity: 1, unitCost: 1 } }))],
|
||||||
|
["dispatch at B", await dispatch.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, morningDispatched: 1 } }))],
|
||||||
|
["count at B", await eveningCount.POST(req("POST", "/x", { as, body: { branchId: b.branchA, productId: b.water20, eveningPhysicalCount: 1 } }))],
|
||||||
|
["sale of B's product", await cash.POST(req("POST", "/x", { as: a.cashierA, body: { sales: [{ clientUuid: uuid(), lines: [{ productId: b.water20, quantity: 1 }] }] } }))],
|
||||||
|
];
|
||||||
|
for (const [label, res] of writes) {
|
||||||
|
if (label.startsWith("sale")) {
|
||||||
|
expect((await json(res)).body.results[0].status, label).toBe("rejected");
|
||||||
|
} else {
|
||||||
|
expect(res.status, label).toBeGreaterThanOrEqual(400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await stockOf(b.businessId, b.branchA, b.water20)).toBe(before.stockA);
|
||||||
|
expect(await db(() => User.findById(b.cashierA.id).lean())).toMatchObject({
|
||||||
|
isActive: before.cashier!.isActive,
|
||||||
|
branchId: before.cashier!.branchId,
|
||||||
|
});
|
||||||
|
expect(await Transaction.countDocuments({ businessId: b.businessId })).toBe(before.sales);
|
||||||
|
expect(await Expense.countDocuments({ businessId: b.businessId })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+7
-7
@@ -4,7 +4,7 @@ import { Product, BranchPricing } from "@/lib/server/models/Product";
|
|||||||
import { stockOf } from "@/lib/server/stock";
|
import { stockOf } from "@/lib/server/stock";
|
||||||
import * as cash from "@/app/api/transactions/cash/route";
|
import * as cash from "@/app/api/transactions/cash/route";
|
||||||
import type { SaleResult } from "@/lib/syncProtocol";
|
import type { SaleResult } from "@/lib/syncProtocol";
|
||||||
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
|
import { json, makeTenant, req, uuid, type Actor, type Tenant, db } from "./helpers";
|
||||||
|
|
||||||
function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) {
|
function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -31,7 +31,7 @@ describe("offline sale sync", () => {
|
|||||||
const { status, body } = await post(t.cashierA, [s]);
|
const { status, body } = await post(t.cashierA, [s]);
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]);
|
expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]);
|
||||||
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(2);
|
expect(await db(() => Transaction.countDocuments({ saleId: s.clientUuid }))).toBe(2);
|
||||||
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2);
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2);
|
||||||
expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3);
|
expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3);
|
||||||
});
|
});
|
||||||
@@ -42,7 +42,7 @@ describe("offline sale sync", () => {
|
|||||||
await post(t.cashierA, [s]);
|
await post(t.cashierA, [s]);
|
||||||
const replay = await post(t.cashierA, [s]);
|
const replay = await post(t.cashierA, [s]);
|
||||||
expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" });
|
expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" });
|
||||||
expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(1);
|
expect(await db(() => Transaction.countDocuments({ saleId: s.clientUuid }))).toBe(1);
|
||||||
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1);
|
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ describe("offline sale sync", () => {
|
|||||||
const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]);
|
const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]);
|
||||||
const { body } = await post(t.cashierA, [otherCashiersSale]);
|
const { body } = await post(t.cashierA, [otherCashiersSale]);
|
||||||
expect(body.results[0].status).toBe("rejected");
|
expect(body.results[0].status).toBe("rejected");
|
||||||
expect(await Transaction.countDocuments({})).toBe(0);
|
expect(await db(() => Transaction.countDocuments({}))).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a sale at the branch where it happened after the cashier is moved", async () => {
|
it("keeps a sale at the branch where it happened after the cashier is moved", async () => {
|
||||||
@@ -86,7 +86,7 @@ describe("offline sale sync", () => {
|
|||||||
const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A
|
const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A
|
||||||
const moved = { ...t.cashierA, branchId: t.branchB };
|
const moved = { ...t.cashierA, branchId: t.branchB };
|
||||||
await post(moved, [s]);
|
await post(moved, [s]);
|
||||||
const txn = await Transaction.findOne({ saleId: s.clientUuid });
|
const txn = await db(() => Transaction.findOne({ saleId: s.clientUuid }));
|
||||||
expect(txn!.branchId.toString()).toBe(t.branchA);
|
expect(txn!.branchId.toString()).toBe(t.branchA);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,13 +97,13 @@ describe("offline sale sync", () => {
|
|||||||
const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA });
|
const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA });
|
||||||
const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]);
|
const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]);
|
||||||
expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]);
|
expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]);
|
||||||
expect(await Transaction.countDocuments({})).toBe(0);
|
expect(await db(() => Transaction.countDocuments({}))).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => {
|
it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => {
|
||||||
const t = await makeTenant();
|
const t = await makeTenant();
|
||||||
await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 });
|
await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 });
|
||||||
await Product.updateOne({ _id: t.water5 }, { isActive: false });
|
await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
|
||||||
const s = sale(t, t.cashierA, [
|
const s = sale(t, t.cashierA, [
|
||||||
{ productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device
|
{ productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device
|
||||||
{ productId: t.water5, quantity: 1, unitPrice: 60 },
|
{ productId: t.water5, quantity: 1, unitPrice: 60 },
|
||||||
|
|||||||
Reference in New Issue
Block a user