Address the 11 October test report findings

The initial test report (run before the pilot-readiness changes) raised
seven findings and a retest plan. This closes the ones still open.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:29:21 +03:00
parent cb962466b3
commit f865837b27
42 changed files with 1486 additions and 193 deletions
+15 -7
View File
@@ -2,11 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { connectDb } from "@/lib/server/db";
import { Branch } from "@/lib/server/models/Branch";
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { authorize, getAuth } from "@/lib/server/auth";
import { normalizeKenyanPhone } from "@/lib/server/phone";
import { branchLimitError, userLimitError, loadTier } from "@/lib/server/subscription";
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -17,7 +18,11 @@ export async function GET(req: NextRequest) {
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
if (auth.role === "SUPER_ADMIN") {
const branches = await Branch.find({ businessId: auth.businessId }).sort({ branchName: 1 }).lean();
// Deactivated branches are only listed on request (the Branches page),
// so every picker elsewhere offers active branches only.
const includeInactive = req.nextUrl.searchParams.get("includeInactive") === "1";
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, ...ACTIVE_BRANCH };
const branches = await Branch.find(filter).sort({ branchName: 1 }).lean();
return NextResponse.json({ branches });
}
if (!auth.branchId) return NextResponse.json({ branches: [] });
@@ -33,7 +38,8 @@ const createSchema = z.object({
name: z.string().trim().min(1).max(120),
email: z.string().email(),
phoneNumber: z.string().min(9).max(15),
password: z.string().min(8, "Password must be at least 8 characters"),
// Omit to invite: the response carries a one-time link for the manager.
password: z.string().min(8, "Password must be at least 8 characters").optional(),
})
.optional(),
});
@@ -53,7 +59,7 @@ export async function POST(req: NextRequest) {
const businessId = auth.businessId;
const tier = await loadTier(businessId);
const branchErr = await branchLimitError(businessId, tier);
const branchErr = await branchLimitError(businessId);
if (branchErr) return NextResponse.json({ error: branchErr }, { status: 403 });
let managerPhone: string | null = null;
@@ -80,8 +86,8 @@ export async function POST(req: NextRequest) {
if (manager && managerPhone) {
try {
const passwordHash = await bcrypt.hash(manager.password, 12);
const managerUser = await User.create({
const passwordHash = manager.password ? await bcrypt.hash(manager.password, 12) : await unusablePasswordHash();
const managerUser = new User({
businessId,
name: manager.name,
role: "BRANCH_MANAGER",
@@ -90,10 +96,12 @@ export async function POST(req: NextRequest) {
email: manager.email.toLowerCase(),
passwordHash,
});
const inviteUrl = manager.password ? null : issueInvite(managerUser, req);
await managerUser.save();
branch.managerId = managerUser._id;
await branch.save();
return NextResponse.json(
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email } },
{ branch, manager: { id: managerUser._id, name: managerUser.name, email: managerUser.email }, inviteUrl },
{ status: 201 }
);
} catch (err: any) {