Initial commit: NetsKey ERP (copper & cream redesign)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||
import { getEnv } from "@/lib/server/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const LOCKOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
deviceId: z.string().uuid(),
|
||||
deviceLabel: z.string().max(120).optional(),
|
||||
});
|
||||
|
||||
async function enforceDeviceBinding(userId: string, deviceId: string) {
|
||||
const env = getEnv();
|
||||
const existing = await UserSession.find({ userId }).sort({ lastUsedAt: -1 }).lean();
|
||||
const alreadyBound = existing.some((s) => s.deviceId === deviceId);
|
||||
if (alreadyBound || existing.length < env.DELIVERY_MAX_DEVICES) {
|
||||
return { allowed: true as const };
|
||||
}
|
||||
return {
|
||||
allowed: false as const,
|
||||
activeDevices: existing.map((s) => ({ deviceId: s.deviceId, label: s.deviceLabel, lastUsedAt: s.lastUsedAt })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = loginSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const { email, password, deviceId, deviceLabel } = parsed.data;
|
||||
|
||||
const user = await User.findOne({ email: email.toLowerCase() }).select("+passwordHash");
|
||||
if (!user || !user.isActive) {
|
||||
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
const minutesLeft = Math.ceil((user.lockedUntil.getTime() - Date.now()) / 60000);
|
||||
return NextResponse.json({ error: `Account locked. Try again in ${minutesLeft} minute(s).` }, { status: 423 });
|
||||
}
|
||||
|
||||
const validPassword = await user.comparePassword(password);
|
||||
if (!validPassword) {
|
||||
user.failedLoginAttempts += 1;
|
||||
if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) {
|
||||
user.lockedUntil = new Date(Date.now() + LOCKOUT_MS);
|
||||
user.failedLoginAttempts = 0;
|
||||
}
|
||||
await user.save();
|
||||
return NextResponse.json({ error: "Incorrect email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
user.failedLoginAttempts = 0;
|
||||
user.lockedUntil = null;
|
||||
await user.save();
|
||||
|
||||
if (user.role === "DELIVERY") {
|
||||
const binding = await enforceDeviceBinding(user._id.toString(), deviceId);
|
||||
if (!binding.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `This account is already active on ${getEnv().DELIVERY_MAX_DEVICES} device(s). Log out one to continue.`,
|
||||
activeDevices: binding.activeDevices,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const refreshToken = generateRefreshToken();
|
||||
const refreshTokenHash = hashRefreshToken(refreshToken);
|
||||
const expiresAt = new Date(Date.now() + refreshTtlMs());
|
||||
|
||||
await UserSession.findOneAndUpdate(
|
||||
{ userId: user._id, deviceId },
|
||||
{ refreshTokenHash, expiresAt, deviceLabel: deviceLabel ?? null, lastUsedAt: new Date() },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
const accessToken = signAccessToken({
|
||||
sub: user._id.toString(),
|
||||
role: user.role,
|
||||
branchId: user.branchId ? user.branchId.toString() : null,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
||||
|
||||
const res = NextResponse.json({
|
||||
user: {
|
||||
id: user._id,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
branchId: user.branchId,
|
||||
branchName: branch?.branchName ?? null,
|
||||
phoneNumber: user.phoneNumber,
|
||||
email: user.email,
|
||||
},
|
||||
redirectTo: user.role === "DELIVERY" ? "/terminal" : "/admin/dashboard",
|
||||
});
|
||||
|
||||
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
||||
res.cookies.set(REFRESH_COOKIE, refreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import { getAuth, ACCESS_COOKIE, REFRESH_COOKIE } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
|
||||
const res = NextResponse.json({ ok: true });
|
||||
if (auth) {
|
||||
await UserSession.deleteOne({ userId: auth.sub, deviceId: auth.deviceId });
|
||||
}
|
||||
res.cookies.set(ACCESS_COOKIE, "", { path: "/", maxAge: 0 });
|
||||
res.cookies.set(REFRESH_COOKIE, "", { path: "/api/auth", maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { getAuth } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const user = await User.findById(auth.sub).lean();
|
||||
if (!user || !user.isActive) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
const branch = user.branchId ? await Branch.findById(user.branchId).lean() : null;
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user._id,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
branchId: user.branchId,
|
||||
branchName: branch?.branchName ?? null,
|
||||
phoneNumber: user.phoneNumber,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import { signAccessToken, generateRefreshToken, hashRefreshToken, refreshTtlMs } from "@/lib/server/jwt";
|
||||
import { ACCESS_COOKIE, REFRESH_COOKIE, accessCookieOptions, refreshCookieOptions } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
|
||||
const refreshToken = req.cookies.get(REFRESH_COOKIE)?.value;
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const deviceId = body?.deviceId as string | undefined;
|
||||
if (!refreshToken || !deviceId) {
|
||||
return NextResponse.json({ error: "No active session" }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokenHash = hashRefreshToken(refreshToken);
|
||||
const session = await UserSession.findOne({ deviceId, refreshTokenHash: tokenHash });
|
||||
if (!session || session.expiresAt.getTime() < Date.now()) {
|
||||
return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await User.findById(session.userId);
|
||||
if (!user || !user.isActive) {
|
||||
await UserSession.deleteOne({ _id: session._id });
|
||||
return NextResponse.json({ error: "Account no longer active" }, { status: 401 });
|
||||
}
|
||||
|
||||
const newRefreshToken = generateRefreshToken();
|
||||
session.refreshTokenHash = hashRefreshToken(newRefreshToken);
|
||||
session.lastUsedAt = new Date();
|
||||
session.expiresAt = new Date(Date.now() + refreshTtlMs());
|
||||
await session.save();
|
||||
|
||||
const accessToken = signAccessToken({
|
||||
sub: user._id.toString(),
|
||||
role: user.role,
|
||||
branchId: user.branchId ? user.branchId.toString() : null,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set(ACCESS_COOKIE, accessToken, accessCookieOptions());
|
||||
res.cookies.set(REFRESH_COOKIE, newRefreshToken, refreshCookieOptions(Math.floor(refreshTtlMs() / 1000)));
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import { getAuth } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { deviceId: string } }) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
await UserSession.deleteOne({ userId: auth.sub, deviceId: params.deviceId });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { UserSession } from "@/lib/server/models/UserSession";
|
||||
import { getAuth } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const sessions = await UserSession.find({ userId: auth.sub }).sort({ lastUsedAt: -1 }).lean();
|
||||
return NextResponse.json({
|
||||
sessions: sessions.map((s) => ({
|
||||
deviceId: s.deviceId,
|
||||
label: s.deviceLabel,
|
||||
lastUsedAt: s.lastUsedAt,
|
||||
isCurrent: s.deviceId === auth.deviceId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { getAuth, requireRole } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
if (auth.role === "SUPER_ADMIN") {
|
||||
const branches = await Branch.find().sort({ branchName: 1 }).lean();
|
||||
return NextResponse.json({ branches });
|
||||
}
|
||||
if (!auth.branchId) return NextResponse.json({ branches: [] });
|
||||
const branch = await Branch.findById(auth.branchId).lean();
|
||||
return NextResponse.json({ branches: branch ? [branch] : [] });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
branchName: z.string().trim().min(1).max(120),
|
||||
locationCity: z.string().trim().min(1).max(120),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!requireRole(auth, "SUPER_ADMIN")) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const branch = await Branch.create(parsed.data);
|
||||
return NextResponse.json({ branch }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import mongoose from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
||||
const date = req.nextUrl.searchParams.get("date") || new Date().toISOString().slice(0, 10);
|
||||
|
||||
const invMatch: Record<string, unknown> = { date };
|
||||
if (branchId) invMatch.branchId = new mongoose.Types.ObjectId(branchId);
|
||||
|
||||
const inventoryRows = await DailyInventory.aggregate([
|
||||
{ $match: invMatch },
|
||||
{ $lookup: { from: "products", localField: "productId", foreignField: "_id", as: "product" } },
|
||||
{ $unwind: "$product" },
|
||||
{ $lookup: { from: "branches", localField: "branchId", foreignField: "_id", as: "branch" } },
|
||||
{ $unwind: "$branch" },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
branchId: 1,
|
||||
branchName: "$branch.branchName",
|
||||
productId: 1,
|
||||
productLabel: "$product.label",
|
||||
sizeLiters: "$product.sizeLiters",
|
||||
morningDispatched: 1,
|
||||
eveningPhysicalCount: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const dayStart = new Date(date + "T00:00:00.000Z");
|
||||
const dayEnd = new Date(dayStart);
|
||||
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1);
|
||||
|
||||
const soldMatch: Record<string, unknown> = {
|
||||
createdAt: { $gte: dayStart, $lt: dayEnd },
|
||||
paymentStatus: "SUCCESS",
|
||||
};
|
||||
if (branchId) soldMatch.branchId = new mongoose.Types.ObjectId(branchId);
|
||||
|
||||
const soldRows = await Transaction.aggregate([
|
||||
{ $match: soldMatch },
|
||||
{ $group: { _id: { branchId: "$branchId", productId: "$productId" }, totalSold: { $sum: "$quantity" } } },
|
||||
]);
|
||||
const soldMap = new Map(
|
||||
soldRows.map((r) => [r._id.branchId.toString() + ":" + r._id.productId.toString(), r.totalSold as number])
|
||||
);
|
||||
|
||||
const rows = inventoryRows.map((row: any) => {
|
||||
const key = row.branchId.toString() + ":" + row.productId.toString();
|
||||
const totalSold = soldMap.get(key) ?? 0;
|
||||
const expectedRemaining = row.morningDispatched - totalSold;
|
||||
const reconciledOk =
|
||||
row.eveningPhysicalCount != null ? row.eveningPhysicalCount === expectedRemaining : null;
|
||||
return { ...row, totalSold, expectedRemaining, reconciledOk };
|
||||
});
|
||||
|
||||
return NextResponse.json({ date, rows });
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import mongoose from "mongoose";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function todayRange() {
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
||||
const { start, end } = todayRange();
|
||||
|
||||
const matchStage: Record<string, unknown> = { createdAt: { $gte: start, $lt: end } };
|
||||
if (branchId) matchStage.branchId = new mongoose.Types.ObjectId(branchId);
|
||||
|
||||
const [totals] = await Transaction.aggregate([
|
||||
{ $match: matchStage },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
cashTotal: {
|
||||
$sum: {
|
||||
$cond: [
|
||||
{ $and: [{ $eq: ["$paymentMethod", "CASH"] }, { $eq: ["$paymentStatus", "SUCCESS"] }] },
|
||||
"$amountTotal",
|
||||
0,
|
||||
],
|
||||
},
|
||||
},
|
||||
mpesaTotal: {
|
||||
$sum: {
|
||||
$cond: [
|
||||
{ $and: [{ $eq: ["$paymentMethod", "MPESA"] }, { $eq: ["$paymentStatus", "SUCCESS"] }] },
|
||||
"$amountTotal",
|
||||
0,
|
||||
],
|
||||
},
|
||||
},
|
||||
totalSales: { $sum: { $cond: [{ $eq: ["$paymentStatus", "SUCCESS"] }, 1, 0] } },
|
||||
pendingMpesa: {
|
||||
$sum: {
|
||||
$cond: [
|
||||
{ $and: [{ $eq: ["$paymentMethod", "MPESA"] }, { $eq: ["$paymentStatus", "PENDING"] }] },
|
||||
1,
|
||||
0,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let branchRanking: any[] = [];
|
||||
if (!branchId) {
|
||||
branchRanking = await Transaction.aggregate([
|
||||
{ $match: { createdAt: { $gte: start, $lt: end }, paymentStatus: "SUCCESS" } },
|
||||
{
|
||||
$group: {
|
||||
_id: "$branchId",
|
||||
revenue: { $sum: "$amountTotal" },
|
||||
salesCount: { $sum: 1 },
|
||||
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
|
||||
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
|
||||
},
|
||||
},
|
||||
{ $sort: { revenue: -1 } },
|
||||
{ $lookup: { from: "branches", localField: "_id", foreignField: "_id", as: "branch" } },
|
||||
{ $unwind: "$branch" },
|
||||
{
|
||||
$project: {
|
||||
_id: 0,
|
||||
branchId: "$_id",
|
||||
branchName: "$branch.branchName",
|
||||
locationCity: "$branch.locationCity",
|
||||
revenue: 1,
|
||||
salesCount: 1,
|
||||
cashTotal: 1,
|
||||
mpesaTotal: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
scope: branchId ? "branch" : "global",
|
||||
totals: totals ?? { cashTotal: 0, mpesaTotal: 0, totalSales: 0, pendingMpesa: 0 },
|
||||
branchRanking,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Expense, EXPENSE_CATEGORIES } from "@/lib/server/models/Expense";
|
||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/expenses?branchId=&from=&to=
|
||||
// SUPER_ADMIN sees all (optionally filtered by branch); everyone else is
|
||||
// hard-scoped to their own branch regardless of query params.
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
||||
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (scopeBranch) filter.branchId = scopeBranch;
|
||||
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
if (from || to) {
|
||||
const createdAt: Record<string, Date> = {};
|
||||
if (from) createdAt.$gte = new Date(from);
|
||||
if (to) createdAt.$lte = new Date(to);
|
||||
filter.createdAt = createdAt;
|
||||
}
|
||||
|
||||
const expenses = await Expense.find(filter)
|
||||
.populate("branchId", "branchName")
|
||||
.populate("recordedBy", "name")
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(500)
|
||||
.lean();
|
||||
|
||||
return NextResponse.json({ expenses });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
branchId: z.string().trim().min(1).optional(),
|
||||
category: z.enum(EXPENSE_CATEGORIES),
|
||||
amount: z.number().positive().max(100_000_000),
|
||||
description: z.string().trim().min(1).max(300),
|
||||
reference: z.string().trim().max(80).optional(),
|
||||
notes: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
// POST /api/expenses — any authenticated staff can record an expense, but
|
||||
// only against a branch they are allowed to touch.
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the branch this expense is booked against, then enforce scope.
|
||||
let branchId: string | null;
|
||||
if (auth.role === "SUPER_ADMIN") {
|
||||
branchId = parsed.data.branchId ?? null;
|
||||
if (!branchId) {
|
||||
return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
branchId = auth.branchId;
|
||||
// A scoped user may not book an expense onto another branch.
|
||||
if (parsed.data.branchId && parsed.data.branchId !== auth.branchId) {
|
||||
return NextResponse.json({ error: "Cannot record for another branch" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const expense = await Expense.create({
|
||||
branchId,
|
||||
category: parsed.data.category,
|
||||
amount: parsed.data.amount,
|
||||
description: parsed.data.description,
|
||||
reference: parsed.data.reference ?? null,
|
||||
notes: parsed.data.notes ?? null,
|
||||
recordedBy: auth.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({ expense }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await connectDb();
|
||||
return NextResponse.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ status: "error", detail: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function todayLocal(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const dispatchSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
morningDispatched: z.number().int().min(0),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
branchId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = dispatchSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
|
||||
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
||||
|
||||
const date = parsed.data.date ?? todayLocal();
|
||||
const row = await DailyInventory.findOneAndUpdate(
|
||||
{ branchId, productId: parsed.data.productId, date },
|
||||
{ morningDispatched: parsed.data.morningDispatched, recordedBy: auth!.sub },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
return NextResponse.json({ inventory: row });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { DailyInventory } from "@/lib/server/models/DailyInventory";
|
||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function todayLocal(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const countSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
eveningPhysicalCount: z.number().int().min(0),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
branchId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = countSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const branchId = resolveBranchScope(auth!, parsed.data.branchId);
|
||||
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
||||
|
||||
const date = parsed.data.date ?? todayLocal();
|
||||
const row = await DailyInventory.findOneAndUpdate(
|
||||
{ branchId, productId: parsed.data.productId, date },
|
||||
{ eveningPhysicalCount: parsed.data.eveningPhysicalCount, recordedBy: auth!.sub },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
return NextResponse.json({ inventory: row });
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function getSourceIp(req: NextRequest): string {
|
||||
const fwd = req.headers.get("x-forwarded-for");
|
||||
if (fwd) return fwd.split(",")[0].trim();
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function isIpAllowed(ip: string): boolean {
|
||||
const allowlist = process.env.DARAJA_CALLBACK_IP_ALLOWLIST;
|
||||
if (!allowlist) return true;
|
||||
return allowlist.split(",").map((s) => s.trim()).includes(ip);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const ack = () => NextResponse.json({ ResultCode: 0, ResultDesc: "Accepted" });
|
||||
const sourceIp = getSourceIp(req);
|
||||
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await req.json();
|
||||
} catch {
|
||||
await MpesaCallbackLog.create({
|
||||
sourceIp,
|
||||
rawPayload: { unparsable: true },
|
||||
processingNote: "Malformed JSON body",
|
||||
});
|
||||
return ack();
|
||||
}
|
||||
|
||||
const callback = payload?.Body?.stkCallback;
|
||||
const checkoutRequestId: string | undefined = callback?.CheckoutRequestID;
|
||||
const resultCode: number | undefined = callback?.ResultCode;
|
||||
const resultDesc: string = callback?.ResultDesc ?? "";
|
||||
|
||||
if (!isIpAllowed(sourceIp)) {
|
||||
await MpesaCallbackLog.create({
|
||||
sourceIp,
|
||||
checkoutRequestId: checkoutRequestId ?? null,
|
||||
resultCode: resultCode ?? null,
|
||||
rawPayload: payload,
|
||||
processingNote: "Rejected: source IP not in DARAJA_CALLBACK_IP_ALLOWLIST",
|
||||
});
|
||||
return ack();
|
||||
}
|
||||
|
||||
if (!checkoutRequestId) {
|
||||
await MpesaCallbackLog.create({
|
||||
sourceIp,
|
||||
rawPayload: payload ?? {},
|
||||
processingNote: "No CheckoutRequestID in payload",
|
||||
});
|
||||
return ack();
|
||||
}
|
||||
|
||||
let mpesaReceiptNumber: string | null = null;
|
||||
if (resultCode === 0 && callback.CallbackMetadata?.Item) {
|
||||
const items: { Name: string; Value: string | number }[] = callback.CallbackMetadata.Item;
|
||||
const receiptItem = items.find((i: any) => i.Name === "MpesaReceiptNumber");
|
||||
mpesaReceiptNumber = receiptItem ? String(receiptItem.Value) : null;
|
||||
}
|
||||
|
||||
const txn = await Transaction.findOne({ "mpesaDetails.checkoutRequestId": checkoutRequestId });
|
||||
|
||||
if (!txn) {
|
||||
await MpesaCallbackLog.create({
|
||||
sourceIp,
|
||||
checkoutRequestId,
|
||||
resultCode: resultCode ?? null,
|
||||
rawPayload: payload,
|
||||
processingNote: "No matching transaction for CheckoutRequestID",
|
||||
});
|
||||
return ack();
|
||||
}
|
||||
|
||||
if (txn.paymentStatus !== "PENDING") {
|
||||
await MpesaCallbackLog.create({
|
||||
sourceIp,
|
||||
checkoutRequestId,
|
||||
resultCode: resultCode ?? null,
|
||||
matchedTransactionId: txn._id,
|
||||
rawPayload: payload,
|
||||
processingNote: "Ignored: transaction already " + txn.paymentStatus,
|
||||
});
|
||||
return ack();
|
||||
}
|
||||
|
||||
const newStatus = resultCode === 0 ? "SUCCESS" : "FAILED";
|
||||
txn.paymentStatus = newStatus;
|
||||
txn.mpesaDetails.resultCode = resultCode ?? null;
|
||||
txn.mpesaDetails.resultDesc = resultDesc;
|
||||
txn.mpesaDetails.receiptNumber = mpesaReceiptNumber;
|
||||
await txn.save();
|
||||
|
||||
await MpesaCallbackLog.create({
|
||||
sourceIp,
|
||||
checkoutRequestId,
|
||||
resultCode: resultCode ?? null,
|
||||
matchedTransactionId: txn._id,
|
||||
rawPayload: payload,
|
||||
processingNote: "Applied: transaction set to " + newStatus,
|
||||
});
|
||||
|
||||
return ack();
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { getAuth } from "@/lib/server/auth";
|
||||
import { queryStkStatus } from "@/lib/server/mpesa";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest, { params }: { params: { transactionId: string } }) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const txn = await Transaction.findById(params.transactionId);
|
||||
if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 });
|
||||
|
||||
const ageMs = Date.now() - txn.createdAt.getTime();
|
||||
if (txn.paymentStatus === "PENDING" && txn.mpesaDetails.checkoutRequestId && ageMs > 15000) {
|
||||
try {
|
||||
const result = await queryStkStatus(txn.mpesaDetails.checkoutRequestId);
|
||||
const code = Number(result.ResultCode);
|
||||
if (!Number.isNaN(code) && code !== 1032) {
|
||||
const fresh = await Transaction.findOne({ _id: txn._id, paymentStatus: "PENDING" });
|
||||
if (fresh) {
|
||||
fresh.paymentStatus = code === 0 ? "SUCCESS" : "FAILED";
|
||||
fresh.mpesaDetails.resultCode = code;
|
||||
fresh.mpesaDetails.resultDesc = result.ResultDesc;
|
||||
await fresh.save();
|
||||
return NextResponse.json(fresh);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Daraja query failed/still pending — fall through and return current row.
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(txn);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||
import { initiateStkPush } from "@/lib/server/mpesa";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const stkSchema = z.object({
|
||||
customerName: z.string().trim().max(120).optional(),
|
||||
customerPhone: z.string().trim().min(9).max(15),
|
||||
productId: z.string().min(1),
|
||||
quantity: z.number().int().min(1).max(500),
|
||||
});
|
||||
|
||||
const hits = new Map<string, { count: number; resetAt: number }>();
|
||||
function rateLimited(key: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = hits.get(key);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
hits.set(key, { count: 1, resetAt: now + 60_000 });
|
||||
return false;
|
||||
}
|
||||
if (entry.count >= 5) return true;
|
||||
entry.count += 1;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (rateLimited(auth!.sub)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many payment prompts sent recently. Wait a moment and try again." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = stkSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const branchId = resolveBranchScope(auth!);
|
||||
if (!branchId) {
|
||||
return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { customerName, customerPhone, productId, quantity } = parsed.data;
|
||||
|
||||
let phone: string;
|
||||
try {
|
||||
phone = normalizeKenyanPhone(customerPhone);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const product = await Product.findById(productId).lean();
|
||||
if (!product || !product.isActive) {
|
||||
return NextResponse.json({ error: "Selected product is not available" }, { status: 400 });
|
||||
}
|
||||
const override = await BranchPricing.findOne({ branchId, productId }).lean();
|
||||
const unitPrice = override?.unitPrice ?? product.unitPrice;
|
||||
const amount = Math.round(quantity * unitPrice * 100) / 100;
|
||||
if (amount <= 0) {
|
||||
return NextResponse.json({ error: "Invalid sale amount" }, { status: 400 });
|
||||
}
|
||||
|
||||
const branch = await Branch.findById(branchId).lean();
|
||||
|
||||
const txn = await Transaction.create({
|
||||
branchId,
|
||||
staffId: auth!.sub,
|
||||
customerName: customerName ?? null,
|
||||
customerPhone: phone,
|
||||
productId: product._id,
|
||||
sizeLiters: product.sizeLiters,
|
||||
quantity,
|
||||
unitPrice,
|
||||
amountTotal: amount,
|
||||
paymentMethod: "MPESA",
|
||||
paymentStatus: "PENDING",
|
||||
clientUuid: `mpesa-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
});
|
||||
|
||||
try {
|
||||
const stk = await initiateStkPush({
|
||||
phone,
|
||||
amount,
|
||||
accountReference: branch?.branchName ?? "Vessel",
|
||||
transactionDesc: `${quantity}x${product.sizeLiters}L`,
|
||||
});
|
||||
|
||||
if (stk.ResponseCode !== "0") {
|
||||
txn.paymentStatus = "FAILED";
|
||||
txn.mpesaDetails.resultDesc = stk.ResponseDescription;
|
||||
await txn.save();
|
||||
return NextResponse.json({ error: stk.ResponseDescription || "Payment prompt was rejected" }, { status: 502 });
|
||||
}
|
||||
|
||||
txn.mpesaDetails.checkoutRequestId = stk.CheckoutRequestID;
|
||||
txn.mpesaDetails.merchantRequestId = stk.MerchantRequestID;
|
||||
await txn.save();
|
||||
|
||||
return NextResponse.json({
|
||||
transactionId: txn._id,
|
||||
checkoutRequestId: stk.CheckoutRequestID,
|
||||
customerMessage: stk.CustomerMessage,
|
||||
});
|
||||
} catch (err: any) {
|
||||
txn.paymentStatus = "FAILED";
|
||||
txn.mpesaDetails.resultDesc = err?.response?.data?.errorMessage ?? err.message;
|
||||
await txn.save();
|
||||
return NextResponse.json({ error: "Could not reach M-Pesa. Please retry." }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { getAuth, requireRole } from "@/lib/server/auth";
|
||||
|
||||
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(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = updateSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
if (Object.keys(parsed.data).length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
|
||||
}
|
||||
|
||||
const product = await Product.findByIdAndUpdate(params.id, parsed.data, { new: true });
|
||||
if (!product) return NextResponse.json({ error: "Product not found" }, { status: 404 });
|
||||
return NextResponse.json({ product });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { getAuth, requireRole } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const products = await Product.find({ isActive: true }).sort({ sortOrder: 1 }).lean();
|
||||
return NextResponse.json({ products });
|
||||
}
|
||||
|
||||
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(),
|
||||
category: z.string().trim().min(1).max(40).optional(),
|
||||
unitPrice: z.number().nonnegative(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const product = await Product.create(parsed.data);
|
||||
return NextResponse.json({ product }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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 { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/purchases?branchId=&from=&to=
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const scopeBranch = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
||||
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (scopeBranch) filter.branchId = scopeBranch;
|
||||
|
||||
const from = url.searchParams.get("from");
|
||||
const to = url.searchParams.get("to");
|
||||
if (from || to) {
|
||||
const createdAt: Record<string, Date> = {};
|
||||
if (from) createdAt.$gte = new Date(from);
|
||||
if (to) createdAt.$lte = new Date(to);
|
||||
filter.createdAt = createdAt;
|
||||
}
|
||||
|
||||
const purchases = await Purchase.find(filter)
|
||||
.populate("branchId", "branchName")
|
||||
.populate("recordedBy", "name")
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(500)
|
||||
.lean();
|
||||
|
||||
return NextResponse.json({ purchases });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
branchId: z.string().trim().min(1).optional(),
|
||||
supplierName: z.string().trim().min(1).max(120),
|
||||
productId: z.string().trim().min(1).optional(),
|
||||
productLabel: z.string().trim().min(1).max(120).optional(),
|
||||
quantity: z.number().int().positive().max(1_000_000),
|
||||
unitCost: z.number().nonnegative().max(100_000_000),
|
||||
reference: z.string().trim().max(80).optional(),
|
||||
notes: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
// POST /api/purchases — records a supplier purchase. The buying price is
|
||||
// snapshotted (unitCost/totalCost) so historical records never shift when
|
||||
// product prices change later. A completed purchase conceptually increases
|
||||
// branch stock.
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid request" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve branch + enforce scope.
|
||||
let branchId: string | null;
|
||||
if (auth.role === "SUPER_ADMIN") {
|
||||
branchId = parsed.data.branchId ?? null;
|
||||
if (!branchId) {
|
||||
return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
||||
}
|
||||
} else {
|
||||
branchId = auth.branchId;
|
||||
if (parsed.data.branchId && parsed.data.branchId !== auth.branchId) {
|
||||
return NextResponse.json({ error: "Cannot record for another branch" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a human-readable product label snapshot. Prefer an explicit
|
||||
// productId lookup; fall back to a free-text label for ad-hoc items.
|
||||
let productLabel = parsed.data.productLabel ?? null;
|
||||
let productId: string | null = parsed.data.productId ?? null;
|
||||
if (productId) {
|
||||
const product = await Product.findById(productId).lean();
|
||||
if (!product) {
|
||||
return NextResponse.json({ error: "Product not found" }, { status: 400 });
|
||||
}
|
||||
productLabel = (product as { label: string }).label;
|
||||
}
|
||||
if (!productLabel) {
|
||||
return NextResponse.json({ error: "productId or productLabel is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const totalCost = parsed.data.quantity * parsed.data.unitCost;
|
||||
|
||||
const purchase = await Purchase.create({
|
||||
branchId,
|
||||
supplierName: parsed.data.supplierName,
|
||||
productId,
|
||||
productLabel,
|
||||
quantity: parsed.data.quantity,
|
||||
unitCost: parsed.data.unitCost,
|
||||
totalCost,
|
||||
reference: parsed.data.reference ?? null,
|
||||
notes: parsed.data.notes ?? null,
|
||||
recordedBy: auth.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({ purchase }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import mongoose 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 { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
||||
|
||||
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
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const branchId = resolveBranchScope(auth, url.searchParams.get("branchId"));
|
||||
|
||||
// Default range: start of the current month -> now.
|
||||
const now = new Date();
|
||||
const defaultFrom = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
||||
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : defaultFrom;
|
||||
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
|
||||
|
||||
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
|
||||
const dateMatch = { createdAt: { $gte: from, $lte: to } };
|
||||
|
||||
const salesMatch: Record<string, unknown> = { ...dateMatch, paymentStatus: "SUCCESS" };
|
||||
if (branchObjId) salesMatch.branchId = branchObjId;
|
||||
|
||||
const costMatch: Record<string, unknown> = { ...dateMatch };
|
||||
if (branchObjId) costMatch.branchId = branchObjId;
|
||||
|
||||
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([
|
||||
Transaction.aggregate([
|
||||
{ $match: salesMatch },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
revenue: { $sum: "$amountTotal" },
|
||||
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
|
||||
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
|
||||
salesCount: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
]),
|
||||
Purchase.aggregate([
|
||||
{ $match: costMatch },
|
||||
{ $group: { _id: null, costOfGoods: { $sum: "$totalCost" }, purchaseCount: { $sum: 1 } } },
|
||||
]),
|
||||
Expense.aggregate([
|
||||
{ $match: costMatch },
|
||||
{ $group: { _id: null, expenses: { $sum: "$amount" }, expenseCount: { $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;
|
||||
|
||||
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,
|
||||
grossProfit,
|
||||
expenses,
|
||||
expenseCount: expenseAgg[0]?.expenseCount ?? 0,
|
||||
netProfit,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { Product, BranchPricing } from "@/lib/server/models/Product";
|
||||
import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const saleSchema = z.object({
|
||||
clientUuid: z.string().uuid(),
|
||||
customerName: z.string().trim().max(120).optional(),
|
||||
customerPhone: z.string().trim().min(9).max(15),
|
||||
productId: z.string().min(1),
|
||||
quantity: z.number().int().min(1).max(500),
|
||||
clientSubmittedAt: z.string().datetime().optional(),
|
||||
});
|
||||
const bodySchema = z.object({ sales: z.array(saleSchema).min(1).max(100) });
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const normalized = Array.isArray(json?.sales) ? json : { sales: [json] };
|
||||
const parsed = bodySchema.safeParse(normalized);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const branchId = resolveBranchScope(auth!);
|
||||
if (!branchId) {
|
||||
return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { sales } = parsed.data;
|
||||
const productIds = Array.from(new Set(sales.map((s) => s.productId)));
|
||||
const products = await Product.find({ _id: { $in: productIds } }).lean();
|
||||
const overrides = await BranchPricing.find({ branchId, productId: { $in: productIds } }).lean();
|
||||
const productMap = new Map(products.map((p) => [p._id.toString(), p]));
|
||||
const overrideMap = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice]));
|
||||
|
||||
const rejected: { clientUuid: string; error: string }[] = [];
|
||||
const inserted: any[] = [];
|
||||
|
||||
for (const sale of sales) {
|
||||
let phone: string;
|
||||
try {
|
||||
phone = normalizeKenyanPhone(sale.customerPhone);
|
||||
} catch (e: any) {
|
||||
rejected.push({ clientUuid: sale.clientUuid, error: e.message });
|
||||
continue;
|
||||
}
|
||||
const product = productMap.get(sale.productId);
|
||||
if (!product || !product.isActive) {
|
||||
rejected.push({ clientUuid: sale.clientUuid, error: "Product not available" });
|
||||
continue;
|
||||
}
|
||||
const unitPrice = overrideMap.get(sale.productId) ?? product.unitPrice;
|
||||
|
||||
try {
|
||||
const created = await Transaction.create({
|
||||
branchId,
|
||||
staffId: auth!.sub,
|
||||
customerName: sale.customerName ?? null,
|
||||
customerPhone: phone,
|
||||
productId: product._id,
|
||||
sizeLiters: product.sizeLiters,
|
||||
quantity: sale.quantity,
|
||||
unitPrice,
|
||||
amountTotal: Math.round(sale.quantity * unitPrice * 100) / 100,
|
||||
paymentMethod: "CASH",
|
||||
paymentStatus: "SUCCESS",
|
||||
clientUuid: sale.clientUuid,
|
||||
syncedFromOffline: !!sale.clientSubmittedAt,
|
||||
clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null,
|
||||
});
|
||||
inserted.push(created);
|
||||
} catch (err: any) {
|
||||
if (err?.code === 11000) continue;
|
||||
rejected.push({ clientUuid: sale.clientUuid, error: "Could not save this sale" });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ inserted, rejected });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { connectDb } from "@/lib/server/db";
|
||||
import { Transaction } from "@/lib/server/models/Transaction";
|
||||
import { getAuth, resolveBranchScope } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
|
||||
const branchId = resolveBranchScope(auth, req.nextUrl.searchParams.get("branchId"));
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200);
|
||||
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (branchId) filter.branchId = branchId;
|
||||
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
filter.createdAt = { $gte: startOfDay };
|
||||
|
||||
const transactions = await Transaction.find(filter)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(limit)
|
||||
.populate("branchId", "branchName")
|
||||
.lean();
|
||||
|
||||
return NextResponse.json({ transactions });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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 { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth";
|
||||
import { normalizeKenyanPhone } from "@/lib/server/phone";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const branchId = resolveBranchScope(auth!, req.nextUrl.searchParams.get("branchId"));
|
||||
const filter: Record<string, unknown> = branchId ? { branchId } : {};
|
||||
const users = await User.find(filter).select("-passwordHash").sort({ name: 1 }).lean();
|
||||
return NextResponse.json({ users });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
role: z.enum(["DELIVERY", "BRANCH_MANAGER", "SUPER_ADMIN"]),
|
||||
branchId: z.string().min(1).optional(),
|
||||
phoneNumber: z.string().min(9).max(15),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
await connectDb();
|
||||
const auth = getAuth(req);
|
||||
if (!requireRole(auth, "BRANCH_MANAGER", "SUPER_ADMIN")) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const json = await req.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const data = parsed.data;
|
||||
|
||||
if (auth!.role === "BRANCH_MANAGER") {
|
||||
if (data.role !== "DELIVERY") {
|
||||
return NextResponse.json({ error: "Branch managers may only create delivery staff accounts" }, { status: 403 });
|
||||
}
|
||||
if (data.branchId && data.branchId !== auth!.branchId) {
|
||||
return NextResponse.json({ error: "Branch managers may only add staff to their own branch" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const branchId = data.role === "SUPER_ADMIN" ? null : data.branchId ?? auth!.branchId ?? undefined;
|
||||
if (data.role !== "SUPER_ADMIN" && !branchId) {
|
||||
return NextResponse.json({ error: "branchId is required for this role" }, { status: 400 });
|
||||
}
|
||||
|
||||
let phone: string;
|
||||
try {
|
||||
phone = normalizeKenyanPhone(data.phoneNumber);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json({ error: e.message }, { status: 400 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||
|
||||
try {
|
||||
const user = await User.create({
|
||||
name: data.name,
|
||||
role: data.role,
|
||||
branchId: branchId ?? null,
|
||||
phoneNumber: phone,
|
||||
email: data.email.toLowerCase(),
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
if (data.role === "BRANCH_MANAGER" && branchId) {
|
||||
await Branch.findByIdAndUpdate(branchId, { managerId: user._id });
|
||||
}
|
||||
|
||||
const obj: any = user.toObject();
|
||||
delete obj.passwordHash;
|
||||
return NextResponse.json({ user: obj }, { 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 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user