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:
@@ -6,7 +6,7 @@ import { Business } from "@/lib/server/models/Business";
|
||||
import { getAuth } from "@/lib/server/auth";
|
||||
import { isPlatformAdminEmail } from "@/lib/server/platform";
|
||||
import { isMpesaEnabled } from "@/lib/server/env";
|
||||
import { TIERS, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -47,7 +47,7 @@ export async function GET(req: NextRequest) {
|
||||
status,
|
||||
trialEndsAt: business?.trialEndsAt ?? null,
|
||||
currentPeriodEnd: business?.currentPeriodEnd ?? null,
|
||||
maxBranches: tierDef.maxBranches,
|
||||
maxBranches: effectiveMaxBranches(tier, status),
|
||||
maxUsers: tierDef.maxUsers,
|
||||
},
|
||||
features: { mpesa: isMpesaEnabled() },
|
||||
|
||||
@@ -29,7 +29,7 @@ const DEFAULT_CATALOG = [
|
||||
{ label: "5L Jerrycan", sizeLiters: 5, unit: "L", category: "Water", unitPrice: 60, sortOrder: 1 },
|
||||
{ label: "10L Jerrycan", sizeLiters: 10, unit: "L", category: "Water", unitPrice: 110, sortOrder: 2 },
|
||||
{ label: "20L Jerrycan", sizeLiters: 20, unit: "L", category: "Water", unitPrice: 200, sortOrder: 3 },
|
||||
{ label: "Tray of Eggs (30)", sizeLiters: 30, unit: "pcs", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
||||
{ label: "Tray of Eggs (30)", sizeLiters: 1, unit: "tray", category: "Eggs", unitPrice: 400, sortOrder: 4 },
|
||||
{ label: "Half Tray (15)", sizeLiters: 15, unit: "pcs", category: "Eggs", unitPrice: 210, sortOrder: 5 },
|
||||
{ label: "Single Egg", sizeLiters: 1, unit: "pcs", category: "Eggs", unitPrice: 15, sortOrder: 6 },
|
||||
];
|
||||
|
||||
@@ -3,7 +3,10 @@ import { z } from "zod";
|
||||
import { isValidObjectId } from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { authorize } from "@/lib/server/auth";
|
||||
import { branchLimitError } from "@/lib/server/subscription";
|
||||
import { getStockLevels } from "@/lib/server/stock";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -11,10 +14,21 @@ const updateSchema = z
|
||||
.object({
|
||||
branchName: z.string().trim().min(1).max(120).optional(),
|
||||
locationCity: z.string().trim().min(1).max(120).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
})
|
||||
.refine((v) => Object.keys(v).length > 0, { message: "No fields to update" });
|
||||
|
||||
// PATCH /api/branches/:id — owner renames a branch or changes its city.
|
||||
/**
|
||||
* PATCH /api/branches/:id — owner renames a branch, or deactivates /
|
||||
* reactivates it. This is how an owner "removes" a branch: nothing is
|
||||
* deleted, so its sales, purchases and stock history stay in every report.
|
||||
*
|
||||
* A branch can only be deactivated once it is empty, so nothing is left
|
||||
* stranded in it:
|
||||
* - no stock on hand (transfer it out or count it to zero first), and
|
||||
* - no active staff (move or deactivate them first).
|
||||
* Reactivating counts against the plan's branch limit again.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
await connectDb();
|
||||
const gate = authorize(req, ["SUPER_ADMIN"]);
|
||||
@@ -28,10 +42,36 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const { isActive, ...details } = parsed.data;
|
||||
|
||||
const branch = await Branch.findOneAndUpdate({ _id: params.id, businessId: auth.businessId }, parsed.data, {
|
||||
new: true,
|
||||
}).lean();
|
||||
const branch = await Branch.findOne({ _id: params.id, businessId: auth.businessId });
|
||||
if (!branch) return NextResponse.json({ error: "Branch not found" }, { status: 404 });
|
||||
const wasActive = branch.isActive !== false;
|
||||
|
||||
if (isActive === false && wasActive) {
|
||||
const stock = (await getStockLevels(auth.businessId, branch._id)).filter((l) => l.quantity !== 0);
|
||||
if (stock.length > 0) {
|
||||
const list = stock.map((l) => `${l.quantity} x ${l.productLabel}`).join(", ");
|
||||
return NextResponse.json(
|
||||
{ error: `Move or count out this branch's stock first (${list}).`, stock },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
const staff = await User.countDocuments({ businessId: auth.businessId, branchId: branch._id, isActive: true });
|
||||
if (staff > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `${staff} active staff member(s) still work at this branch. Move or deactivate them first.` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isActive === true && !wasActive) {
|
||||
const limitError = await branchLimitError(auth.businessId);
|
||||
if (limitError) return NextResponse.json({ error: limitError }, { status: 403 });
|
||||
}
|
||||
|
||||
Object.assign(branch, details);
|
||||
if (isActive !== undefined) branch.isActive = isActive;
|
||||
await branch.save();
|
||||
return NextResponse.json({ branch });
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import 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) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { hashInviteToken } from "@/lib/server/invites";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function findInvitee(token: string) {
|
||||
if (!token || token.length > 100) return null;
|
||||
const user = await User.findOne({ inviteTokenHash: hashInviteToken(token), isActive: true });
|
||||
if (!user || !user.inviteExpiresAt || user.inviteExpiresAt.getTime() < Date.now()) return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
const INVALID = { error: "This invite link is invalid or has expired. Ask your manager for a new one." };
|
||||
|
||||
// GET /api/invites/:token — who the invite is for, so the page can greet them.
|
||||
export async function GET(_req: NextRequest, { params }: { params: { token: string } }) {
|
||||
await connectDb();
|
||||
const user = await findInvitee(params.token);
|
||||
if (!user) return NextResponse.json(INVALID, { status: 404 });
|
||||
const business = await Business.findById(user.businessId).select("name").lean();
|
||||
return NextResponse.json({ name: user.name, email: user.email, businessName: business?.name ?? null });
|
||||
}
|
||||
|
||||
const acceptSchema = z.object({ password: z.string().min(8, "Password must be at least 8 characters") });
|
||||
|
||||
// POST /api/invites/:token — set the password; the link then stops working.
|
||||
export async function POST(req: NextRequest, { params }: { params: { token: string } }) {
|
||||
await connectDb();
|
||||
const parsed = acceptSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const user = await findInvitee(params.token);
|
||||
if (!user) return NextResponse.json(INVALID, { status: 404 });
|
||||
|
||||
user.passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||
user.inviteTokenHash = null;
|
||||
user.inviteExpiresAt = null;
|
||||
user.failedLoginAttempts = 0;
|
||||
user.lockedUntil = null;
|
||||
await user.save();
|
||||
return NextResponse.json({ ok: true, email: user.email });
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Business } from "@/lib/server/models/Business";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { requirePlatformAdmin } from "@/lib/server/platform";
|
||||
import { TIERS, type TierId } from "@/lib/tiers";
|
||||
import { TIERS, effectiveMaxBranches, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -28,6 +28,7 @@ export async function GET(req: NextRequest) {
|
||||
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
||||
]),
|
||||
Branch.aggregate<{ _id: unknown; count: number }>([
|
||||
{ $match: { isActive: { $ne: false } } },
|
||||
{ $group: { _id: "$businessId", count: { $sum: 1 } } },
|
||||
]),
|
||||
]);
|
||||
@@ -58,7 +59,7 @@ export async function GET(req: NextRequest) {
|
||||
userCount: userMap.get(String(b._id)) ?? 0,
|
||||
branchCount: branchMap.get(String(b._id)) ?? 0,
|
||||
maxUsers: TIERS[tier].maxUsers,
|
||||
maxBranches: TIERS[tier].maxBranches,
|
||||
maxBranches: effectiveMaxBranches(tier, (b.subscriptionStatus as SubscriptionStatus) ?? "TRIALING"),
|
||||
owner: owner
|
||||
? { name: owner.name, email: owner.email, phoneNumber: owner.phoneNumber }
|
||||
: null,
|
||||
|
||||
@@ -4,13 +4,16 @@ import { connectDb } from "@/lib/server/db";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { authorize } from "@/lib/server/auth";
|
||||
import { isValidObjectId } from "mongoose";
|
||||
import { PRODUCT_UNITS } from "@/lib/units";
|
||||
import { labelTakenError } from "@/lib/server/products";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const updateSchema = z.object({
|
||||
label: z.string().trim().min(1).max(60).optional(),
|
||||
sizeLiters: z.number().positive().max(1000).optional(),
|
||||
unitPrice: z.number().nonnegative().optional(),
|
||||
unit: z.enum(PRODUCT_UNITS).optional(),
|
||||
unitPrice: z.number().positive("Price must be more than zero").optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
|
||||
@@ -33,6 +36,11 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (parsed.data.label) {
|
||||
const taken = await labelTakenError(auth.businessId, parsed.data.label, params.id);
|
||||
if (taken) return NextResponse.json({ error: taken }, { status: 409 });
|
||||
}
|
||||
|
||||
const product = await Product.findOneAndUpdate(
|
||||
{ _id: params.id, businessId: auth.businessId },
|
||||
parsed.data,
|
||||
|
||||
@@ -2,12 +2,15 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
||||
import { authorize } from "@/lib/server/auth";
|
||||
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||
import { PRODUCT_UNITS } from "@/lib/units";
|
||||
import { labelTakenError } from "@/lib/server/products";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/products — the tenant's active catalogue. For branch-scoped
|
||||
// GET /api/products — the tenant's active catalogue (?includeInactive=1
|
||||
// adds deactivated products, for managers and owners). For branch-scoped
|
||||
// staff the price returned is the price THEIR branch charges (the branch
|
||||
// override when one exists), so the terminal shows exactly what the
|
||||
// server will record.
|
||||
@@ -17,7 +20,10 @@ export async function GET(req: NextRequest) {
|
||||
if (!gate.ok) return gate.response;
|
||||
const auth = gate.auth;
|
||||
|
||||
const products = await Product.find({ businessId: auth.businessId, isActive: true }).sort({ sortOrder: 1 }).lean();
|
||||
const includeInactive =
|
||||
req.nextUrl.searchParams.get("includeInactive") === "1" && MANAGERS.includes(auth.role);
|
||||
const filter = includeInactive ? { businessId: auth.businessId } : { businessId: auth.businessId, isActive: true };
|
||||
const products = await Product.find(filter).sort({ sortOrder: 1 }).lean();
|
||||
if (auth.branchId) {
|
||||
const overrides = await BranchPricing.find({ businessId: auth.businessId, branchId: auth.branchId }).lean();
|
||||
const priceByProduct = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
|
||||
@@ -31,9 +37,9 @@ export async function GET(req: NextRequest) {
|
||||
const createSchema = z.object({
|
||||
label: z.string().trim().min(1).max(60),
|
||||
sizeLiters: z.number().positive().max(1000),
|
||||
unit: z.string().trim().min(1).max(12).optional(),
|
||||
unit: z.enum(PRODUCT_UNITS).optional(),
|
||||
category: z.string().trim().min(1).max(40).optional(),
|
||||
unitPrice: z.number().nonnegative(),
|
||||
unitPrice: z.number().positive("Price must be more than zero"),
|
||||
sortOrder: z.number().int().optional(),
|
||||
lowStockThreshold: z.number().int().min(0).max(1_000_000).optional(),
|
||||
});
|
||||
@@ -51,6 +57,8 @@ export async function POST(req: NextRequest) {
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const taken = await labelTakenError(auth.businessId, parsed.data.label);
|
||||
if (taken) return NextResponse.json({ error: taken }, { status: 409 });
|
||||
const product = await Product.create({ ...parsed.data, businessId: auth.businessId });
|
||||
return NextResponse.json({ product }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Purchase } from "@/lib/server/models/Purchase";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||
import { objectId } from "@/lib/server/validation";
|
||||
import { recordMovement } from "@/lib/server/stock";
|
||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||
@@ -91,7 +91,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// The target branch MUST belong to this tenant.
|
||||
const branchDoc = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
||||
const branchDoc = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
||||
if (!branchDoc) {
|
||||
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||
}
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import mongoose from "mongoose";
|
||||
import mongoose, { Types } from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { Purchase } from "@/lib/server/models/Purchase";
|
||||
import { Expense } from "@/lib/server/models/Expense";
|
||||
import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||
import { startOfMonthNairobi } from "@/lib/server/businessDay";
|
||||
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi } from "@/lib/server/businessDay";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Management / profit overview — NOT full accounting.
|
||||
// Revenue = sum of SUCCESS sales in range
|
||||
// Cost of Goods= sum of purchase cost in range (stock bought in)
|
||||
// Gross Profit = Revenue - Cost of Goods
|
||||
// Expenses = sum of operating expenses in range
|
||||
// Net Profit = Gross Profit - Expenses
|
||||
/**
|
||||
* GET /api/reports/profit?branchId=&fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD
|
||||
*
|
||||
* Management overview, NOT accounting (full accounting is out of the PRD's scope).
|
||||
* Revenue SUCCESS sales in the period
|
||||
* Cost of goods sold units sold x that product's average purchase cost
|
||||
* (all purchases up to the end of the period)
|
||||
* Gross profit revenue - cost of goods sold
|
||||
* Expenses operating expenses in the period
|
||||
* Net profit gross profit - expenses
|
||||
* Shown separately, because buying stock is not the same as using it:
|
||||
* Stock purchased cash spent on stock in the period
|
||||
* Cash surplus revenue - stock purchased - expenses
|
||||
*
|
||||
* Dates are Nairobi business days, inclusive. `from`/`to` (ISO instants)
|
||||
* are still accepted for older clients.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
// Revenue, costs and profit are owner/manager information, never a cashier's.
|
||||
@@ -28,63 +39,111 @@ export async function GET(req: NextRequest) {
|
||||
if (!scope.ok) return scope.response;
|
||||
const branchId = scope.branchId;
|
||||
|
||||
// Default range: start of the current business month (EAT) -> now.
|
||||
const now = new Date();
|
||||
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : startOfMonthNairobi(now);
|
||||
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return badRequest("Invalid from/to date");
|
||||
const range = parseRange(url.searchParams);
|
||||
if ("error" in range) return badRequest(range.error);
|
||||
const { from, to } = range;
|
||||
|
||||
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
||||
const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
|
||||
const dateMatch = { createdAt: { $gte: from, $lte: to } };
|
||||
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
||||
const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) };
|
||||
|
||||
const salesMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId, paymentStatus: "SUCCESS" };
|
||||
if (branchObjId) salesMatch.branchId = branchObjId;
|
||||
|
||||
const costMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId };
|
||||
if (branchObjId) costMatch.branchId = branchObjId;
|
||||
|
||||
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([
|
||||
Transaction.aggregate([
|
||||
{ $match: salesMatch },
|
||||
const [salesByProduct, avgCosts, purchaseAgg, expenseAgg] = await Promise.all([
|
||||
Transaction.aggregate<{
|
||||
_id: Types.ObjectId;
|
||||
revenue: number;
|
||||
cashTotal: number;
|
||||
mpesaTotal: number;
|
||||
units: number;
|
||||
lines: number;
|
||||
}>([
|
||||
{ $match: { ...inPeriod, paymentStatus: "SUCCESS" } },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
_id: "$productId",
|
||||
revenue: { $sum: "$amountTotal" },
|
||||
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
|
||||
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
|
||||
salesCount: { $sum: 1 },
|
||||
units: { $sum: "$quantity" },
|
||||
lines: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
Purchase.aggregate([
|
||||
{ $match: costMatch },
|
||||
{ $group: { _id: null, costOfGoods: { $sum: "$totalCost" }, purchaseCount: { $sum: 1 } } },
|
||||
// Average buying price per product across the business (suppliers
|
||||
// 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" } } },
|
||||
]),
|
||||
Expense.aggregate([
|
||||
{ $match: costMatch },
|
||||
{ $group: { _id: null, expenses: { $sum: "$amount" }, expenseCount: { $sum: 1 } } },
|
||||
Purchase.aggregate<{ total: number; count: number }>([
|
||||
{ $match: inPeriod },
|
||||
{ $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } },
|
||||
]),
|
||||
Expense.aggregate<{ total: number; count: number }>([
|
||||
{ $match: inPeriod },
|
||||
{ $group: { _id: null, total: { $sum: "$amount" }, count: { $sum: 1 } } },
|
||||
]),
|
||||
]);
|
||||
|
||||
const revenue = salesAgg[0]?.revenue ?? 0;
|
||||
const costOfGoods = purchaseAgg[0]?.costOfGoods ?? 0;
|
||||
const expenses = expenseAgg[0]?.expenses ?? 0;
|
||||
const grossProfit = revenue - costOfGoods;
|
||||
const netProfit = grossProfit - expenses;
|
||||
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, unitsWithoutCost = 0, costOfGoodsSold = 0;
|
||||
for (const s of salesByProduct) {
|
||||
revenue += s.revenue;
|
||||
cashTotal += s.cashTotal;
|
||||
mpesaTotal += s.mpesaTotal;
|
||||
salesCount += s.lines;
|
||||
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 stockPurchases = purchaseAgg[0]?.total ?? 0;
|
||||
const expenses = expenseAgg[0]?.total ?? 0;
|
||||
const grossProfit = Math.round((revenue - costOfGoodsSold) * 100) / 100;
|
||||
|
||||
return NextResponse.json({
|
||||
scope: branchId ? "branch" : "global",
|
||||
range: { from: from.toISOString(), to: to.toISOString() },
|
||||
revenue,
|
||||
cashTotal: salesAgg[0]?.cashTotal ?? 0,
|
||||
mpesaTotal: salesAgg[0]?.mpesaTotal ?? 0,
|
||||
salesCount: salesAgg[0]?.salesCount ?? 0,
|
||||
costOfGoods,
|
||||
purchaseCount: purchaseAgg[0]?.purchaseCount ?? 0,
|
||||
cashTotal,
|
||||
mpesaTotal,
|
||||
salesCount,
|
||||
unitsSold,
|
||||
costOfGoodsSold,
|
||||
// Units sold of products never bought through Purchases: their cost
|
||||
// is unknown, so gross profit overstates by their cost.
|
||||
unitsWithoutCost,
|
||||
grossProfit,
|
||||
expenses,
|
||||
expenseCount: expenseAgg[0]?.expenseCount ?? 0,
|
||||
netProfit,
|
||||
expenseCount: expenseAgg[0]?.count ?? 0,
|
||||
netProfit: Math.round((grossProfit - expenses) * 100) / 100,
|
||||
stockPurchases,
|
||||
purchaseCount: purchaseAgg[0]?.count ?? 0,
|
||||
cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
/** The half-open UTC range [from, to) for the request, or a validation error. */
|
||||
function parseRange(params: URLSearchParams): { from: Date; to: Date } | { error: string } {
|
||||
const fromDate = params.get("fromDate");
|
||||
const toDate = params.get("toDate");
|
||||
let from: Date;
|
||||
let to: Date;
|
||||
|
||||
if (fromDate || toDate) {
|
||||
if ((fromDate && !isBusinessDate(fromDate)) || (toDate && !isBusinessDate(toDate))) {
|
||||
return { error: "Dates must be YYYY-MM-DD" };
|
||||
}
|
||||
from = fromDate ? dayRangeNairobi(fromDate).start : startOfMonthNairobi();
|
||||
to = toDate ? dayRangeNairobi(toDate).end : new Date();
|
||||
} else {
|
||||
from = params.get("from") ? new Date(params.get("from")!) : startOfMonthNairobi();
|
||||
to = params.get("to") ? new Date(params.get("to")!) : new Date();
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return { error: "Invalid from/to date" };
|
||||
}
|
||||
|
||||
// A reversed period must not look like a period with no activity.
|
||||
if (from >= to) return { error: "The start date must be on or before the end date." };
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
|
||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||
@@ -97,7 +97,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const [branchCount, product] = await Promise.all([
|
||||
Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId }),
|
||||
Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId, ...ACTIVE_BRANCH }),
|
||||
Product.exists({ _id: data.productId, businessId: auth.businessId }),
|
||||
]);
|
||||
if (branchCount !== 2) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isValidObjectId } from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||
import { issueInvite } from "@/lib/server/invites";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* POST /api/users/:id/invite — a new invite link for someone who has not
|
||||
* accepted theirs yet (lost or expired). The previous link stops working.
|
||||
* Owners: anyone in the business. Managers: their own branch's cashiers.
|
||||
*/
|
||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
await connectDb();
|
||||
const gate = authorize(req, MANAGERS);
|
||||
if (!gate.ok) return gate.response;
|
||||
const auth = gate.auth;
|
||||
|
||||
if (!isValidObjectId(params.id)) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
const user = await User.findOne({ _id: params.id, businessId: auth.businessId });
|
||||
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
|
||||
if (auth.role === "BRANCH_MANAGER" && !(user.role === "DELIVERY" && user.branchId?.toString() === auth.branchId)) {
|
||||
return NextResponse.json({ error: "Branch managers may only invite delivery staff in their own branch" }, { status: 403 });
|
||||
}
|
||||
if (!user.inviteExpiresAt) {
|
||||
return NextResponse.json({ error: "This person has already set their password" }, { status: 409 });
|
||||
}
|
||||
|
||||
const inviteUrl = issueInvite(user, req);
|
||||
await user.save();
|
||||
return NextResponse.json({ inviteUrl, expiresAt: user.inviteExpiresAt });
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { isValidObjectId } from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||
import { Business } from "@/lib/server/models/Business";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import { authorize, MANAGERS } from "@/lib/server/auth";
|
||||
@@ -67,7 +67,7 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
||||
if (target.role === "SUPER_ADMIN") {
|
||||
return NextResponse.json({ error: "Owner accounts are not tied to a branch" }, { status: 400 });
|
||||
}
|
||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).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 });
|
||||
target.branchId = branch._id;
|
||||
}
|
||||
@@ -82,5 +82,6 @@ export async function PATCH(req: NextRequest, { params }: { params: { id: string
|
||||
|
||||
const obj = target.toObject() as unknown as Record<string, unknown>;
|
||||
delete obj.passwordHash;
|
||||
delete obj.inviteTokenHash;
|
||||
return NextResponse.json({ user: obj });
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
|
||||
import { objectId } from "@/lib/server/validation";
|
||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||
import { userLimitError, loadTier } from "@/lib/server/subscription";
|
||||
import { issueInvite, unusablePasswordHash } from "@/lib/server/invites";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -22,8 +23,11 @@ export async function GET(req: NextRequest) {
|
||||
if (!scope.ok) return scope.response;
|
||||
const filter: Record<string, unknown> = { businessId: auth.businessId };
|
||||
if (scope.branchId) filter.branchId = scope.branchId;
|
||||
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
|
||||
return NextResponse.json({ users });
|
||||
const users = await User.find(filter).select("-passwordHash -inviteTokenHash").sort({ name: 1 }).lean();
|
||||
// invitePending: the person has not set their password yet.
|
||||
return NextResponse.json({
|
||||
users: users.map((u) => ({ ...u, invitePending: !!u.inviteExpiresAt })),
|
||||
});
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
@@ -32,7 +36,9 @@ const createSchema = z.object({
|
||||
branchId: objectId.optional(),
|
||||
phoneNumber: z.string().min(9).max(15),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
// Omit the password to invite instead: the response carries a one-time
|
||||
// link the person uses to set their own password.
|
||||
password: z.string().min(8).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -63,7 +69,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (branchId) {
|
||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId }).lean();
|
||||
const branch = await Branch.findOne({ _id: branchId, businessId: auth.businessId, ...ACTIVE_BRANCH }).lean();
|
||||
if (!branch) {
|
||||
return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
|
||||
}
|
||||
@@ -81,10 +87,10 @@ export async function POST(req: NextRequest) {
|
||||
const userErr = await userLimitError(auth.businessId, tier);
|
||||
if (userErr) return NextResponse.json({ error: userErr }, { status: 403 });
|
||||
|
||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||
const passwordHash = data.password ? await bcrypt.hash(data.password, 12) : await unusablePasswordHash();
|
||||
|
||||
try {
|
||||
const user = await User.create({
|
||||
const user = new User({
|
||||
businessId: auth.businessId,
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
@@ -93,6 +99,8 @@ export async function POST(req: NextRequest) {
|
||||
email: data.email.toLowerCase(),
|
||||
passwordHash,
|
||||
});
|
||||
const inviteUrl = data.password ? null : issueInvite(user, req);
|
||||
await user.save();
|
||||
|
||||
if (data.role === "BRANCH_MANAGER" && branchId) {
|
||||
await Branch.findOneAndUpdate(
|
||||
@@ -103,7 +111,8 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const obj: any = user.toObject();
|
||||
delete obj.passwordHash;
|
||||
return NextResponse.json({ user: obj }, { status: 201 });
|
||||
delete obj.inviteTokenHash;
|
||||
return NextResponse.json({ user: obj, inviteUrl }, { status: 201 });
|
||||
} catch (err: any) {
|
||||
if (err?.code === 11000) {
|
||||
return NextResponse.json({ error: "A user with this phone number or email already exists" }, { status: 409 });
|
||||
|
||||
Reference in New Issue
Block a user