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:
@@ -108,6 +108,23 @@ export async function queueCounts(userId: string): Promise<{ pending: number; re
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Units this cashier has sold that the given server stock snapshot does
|
||||
* not include yet: sales still pending, plus sales that synced after the
|
||||
* snapshot was taken. Subtracting these from the snapshot gives the stock
|
||||
* the till should show right now, online or offline.
|
||||
*/
|
||||
export function unsyncedQuantities(rows: QueuedSale[], snapshotAt: string | null): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
const notInSnapshot =
|
||||
row.state === "pending" || (row.state === "synced" && (!snapshotAt || (row.syncedAt ?? "") > snapshotAt));
|
||||
if (!notInSnapshot) continue;
|
||||
for (const l of row.lines) out[l.productId] = (out[l.productId] ?? 0) + l.quantity;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The cashier has acknowledged a rejected sale (e.g. reported it to the manager). */
|
||||
export async function dismissRejected(clientUuid: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { IUser } from "@/lib/server/models/User";
|
||||
|
||||
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function hashInviteToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
/** A password nobody knows, for an account whose owner has not accepted their invite yet. */
|
||||
export async function unusablePasswordHash(): Promise<string> {
|
||||
return bcrypt.hash(crypto.randomBytes(32).toString("hex"), 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the user a fresh invite (any earlier link stops working) and return
|
||||
* the link to send them. The caller saves the user.
|
||||
*/
|
||||
export function issueInvite(user: IUser, req: NextRequest): string {
|
||||
const token = crypto.randomBytes(32).toString("base64url");
|
||||
user.inviteTokenHash = hashInviteToken(token);
|
||||
user.inviteExpiresAt = new Date(Date.now() + INVITE_TTL_MS);
|
||||
const origin = process.env.APP_URL?.replace(/\/$/, "") || req.nextUrl.origin;
|
||||
return `${origin}/invite/${token}`;
|
||||
}
|
||||
@@ -6,6 +6,12 @@ export interface IBranch extends Document {
|
||||
branchName: string;
|
||||
locationCity: string;
|
||||
managerId: Types.ObjectId | null;
|
||||
// A deactivated branch keeps all its sales, purchases and stock history
|
||||
// and still appears in reports for past periods, but takes no new staff,
|
||||
// transfers or purchases and does not count towards the plan's limit.
|
||||
// Rows created before this field existed have no value: treat as active
|
||||
// (query with `isActive: { $ne: false }`).
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -16,9 +22,13 @@ const branchSchema = new Schema<IBranch>(
|
||||
branchName: { type: String, required: true, trim: true },
|
||||
locationCity: { type: String, required: true, trim: true },
|
||||
managerId: { type: Schema.Types.ObjectId, ref: "User", default: null },
|
||||
isActive: { type: Boolean, default: true },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
/** Filter for branches that are in use. */
|
||||
export const ACTIVE_BRANCH = { isActive: { $ne: false } } as const;
|
||||
|
||||
export const Branch =
|
||||
(models.Branch as Model<IBranch>) || model<IBranch>("Branch", branchSchema);
|
||||
|
||||
@@ -4,8 +4,10 @@ export interface IProduct extends Document {
|
||||
_id: Types.ObjectId;
|
||||
businessId: Types.ObjectId; // tenant this product belongs to
|
||||
label: string;
|
||||
// Size measured in `unit` (litres for water, a count for side items).
|
||||
// The field keeps its historical name so existing data needs no migration.
|
||||
sizeLiters: number;
|
||||
unit: string; // display unit, e.g. "L" for water, "pcs" / "tray" for eggs
|
||||
unit: string; // see PRODUCT_UNITS in src/lib/units.ts: "L", "pcs", "tray"...
|
||||
category: string; // e.g. "Water", "Eggs"
|
||||
unitPrice: number;
|
||||
sortOrder: number;
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface IUser extends Document {
|
||||
isActive: boolean;
|
||||
failedLoginAttempts: number;
|
||||
lockedUntil: Date | null;
|
||||
// Pending invite: the person sets their own password through a one-time
|
||||
// link. Only the SHA-256 of the token is stored. Until they accept, the
|
||||
// account has a random unknown password, so it cannot be signed into.
|
||||
inviteTokenHash: string | null;
|
||||
inviteExpiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -34,6 +39,8 @@ const userSchema = new Schema<IUser>(
|
||||
isActive: { type: Boolean, default: true },
|
||||
failedLoginAttempts: { type: Number, default: 0 },
|
||||
lockedUntil: { type: Date, default: null },
|
||||
inviteTokenHash: { type: String, default: null, index: { sparse: true } },
|
||||
inviteExpiresAt: { type: Date, default: null },
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Types } from "mongoose";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
|
||||
/**
|
||||
* Two products with the same name (ignoring case) would be impossible to
|
||||
* tell apart on the terminal and in reports, so names are unique per
|
||||
* business — including deactivated products, which can be reactivated.
|
||||
*/
|
||||
export async function labelTakenError(
|
||||
businessId: Types.ObjectId | string,
|
||||
label: string,
|
||||
exceptId?: string
|
||||
): Promise<string | null> {
|
||||
const filter: Record<string, unknown> = { businessId, label: label.trim() };
|
||||
if (exceptId) filter._id = { $ne: exceptId };
|
||||
const clash = await Product.findOne(filter).collation({ locale: "en", strength: 2 }).select("label isActive").lean();
|
||||
if (!clash) return null;
|
||||
return clash.isActive
|
||||
? `A product called "${clash.label}" already exists.`
|
||||
: `A deactivated product called "${clash.label}" already exists. Reactivate it instead.`;
|
||||
}
|
||||
+40
-5
@@ -1,7 +1,8 @@
|
||||
import mongoose, { Types } from "mongoose";
|
||||
import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch";
|
||||
import { Product } from "@/lib/server/models/Product";
|
||||
import { Purchase } from "@/lib/server/models/Purchase";
|
||||
import type { ITransaction } from "@/lib/server/models/Transaction";
|
||||
|
||||
type Id = Types.ObjectId | string;
|
||||
@@ -89,6 +90,7 @@ export interface StockLevel {
|
||||
quantity: number;
|
||||
lowStockThreshold: number;
|
||||
status: StockStatus;
|
||||
productActive: boolean;
|
||||
}
|
||||
|
||||
export function stockStatus(quantity: number, threshold: number): StockStatus {
|
||||
@@ -98,20 +100,22 @@ export function stockStatus(quantity: number, threshold: number): StockStatus {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stock of every active product at every branch in scope — including
|
||||
* zero rows, so an owner sees "0" rather than a missing product. A
|
||||
* Stock of every active product at every active branch in scope —
|
||||
* including zero rows, so an owner sees "0" rather than a missing product.
|
||||
* A deactivated product is listed too wherever it still has stock, so
|
||||
* units bought before deactivation never silently disappear. A
|
||||
* negative quantity is reported as-is: it means sales were recorded
|
||||
* (typically offline) for stock the ledger never received, and the branch
|
||||
* needs a count/adjustment.
|
||||
*/
|
||||
export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise<StockLevel[]> {
|
||||
const bizObj = new mongoose.Types.ObjectId(businessId.toString());
|
||||
const branchFilter: Record<string, unknown> = { businessId: bizObj };
|
||||
const branchFilter: Record<string, unknown> = { businessId: bizObj, ...ACTIVE_BRANCH };
|
||||
if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString());
|
||||
|
||||
const [branches, products, totals] = await Promise.all([
|
||||
Branch.find(branchFilter).sort({ branchName: 1 }).lean(),
|
||||
Product.find({ businessId: bizObj, isActive: true }).sort({ sortOrder: 1 }).lean(),
|
||||
Product.find({ businessId: bizObj }).sort({ sortOrder: 1 }).lean(),
|
||||
StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([
|
||||
{ $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } },
|
||||
{ $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } },
|
||||
@@ -123,6 +127,7 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
|
||||
for (const b of branches) {
|
||||
for (const p of products) {
|
||||
const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0;
|
||||
if (!p.isActive && quantity === 0) continue;
|
||||
const lowStockThreshold = p.lowStockThreshold ?? 10;
|
||||
levels.push({
|
||||
branchId: b._id.toString(),
|
||||
@@ -133,8 +138,38 @@ export async function getStockLevels(businessId: Id, branchId?: Id | null): Prom
|
||||
quantity,
|
||||
lowStockThreshold,
|
||||
status: stockStatus(quantity, lowStockThreshold),
|
||||
productActive: p.isActive,
|
||||
});
|
||||
}
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the PURCHASE movement for every catalogue purchase that has none.
|
||||
* Purchases recorded before the stock ledger existed (or while a write
|
||||
* failed) otherwise never show up as stock. Idempotent: run it as often
|
||||
* as you like. Returns how many movements were added.
|
||||
*/
|
||||
export async function backfillPurchaseMovements(businessId?: Id): Promise<number> {
|
||||
const filter: Record<string, unknown> = { productId: { $ne: null } };
|
||||
if (businessId) filter.businessId = new mongoose.Types.ObjectId(businessId.toString());
|
||||
|
||||
let added = 0;
|
||||
for await (const p of Purchase.find(filter).lean().cursor()) {
|
||||
const refId = p._id.toString();
|
||||
if (await StockMovement.exists({ type: "PURCHASE", refId })) continue;
|
||||
await recordMovement({
|
||||
businessId: p.businessId,
|
||||
branchId: p.branchId,
|
||||
productId: p.productId!,
|
||||
type: "PURCHASE",
|
||||
quantity: p.quantity,
|
||||
refId,
|
||||
note: `From ${p.supplierName} (backfilled)`,
|
||||
recordedBy: p.recordedBy,
|
||||
});
|
||||
added += 1;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
@@ -8,25 +8,27 @@ import { Types } from "mongoose";
|
||||
import { Branch } from "@/lib/server/models/Branch";
|
||||
import { User } from "@/lib/server/models/User";
|
||||
import { Business, IBusiness } from "@/lib/server/models/Business";
|
||||
import { TIERS, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||
import { TIERS, effectiveMaxBranches, isUsable, type TierId, type SubscriptionStatus } from "@/lib/tiers";
|
||||
|
||||
export function tierOf(business: Pick<IBusiness, "tier">): TierId {
|
||||
return (business.tier as TierId) ?? "STARTER";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an error message string if adding one more branch would exceed
|
||||
* the business's plan, or null if it's allowed.
|
||||
* Returns an error message string if adding (or reactivating) one more
|
||||
* branch would exceed the business's plan, or null if it's allowed. Only
|
||||
* active branches count; trials get TRIAL_MAX_BRANCHES.
|
||||
*/
|
||||
export async function branchLimitError(
|
||||
businessId: Types.ObjectId | string,
|
||||
tier: TierId
|
||||
): Promise<string | null> {
|
||||
const max = TIERS[tier].maxBranches;
|
||||
export async function branchLimitError(businessId: Types.ObjectId | string): Promise<string | null> {
|
||||
const biz = await Business.findById(businessId).select("tier subscriptionStatus").lean();
|
||||
const tier = (biz?.tier as TierId) ?? "STARTER";
|
||||
const status = (biz?.subscriptionStatus as SubscriptionStatus) ?? "TRIALING";
|
||||
const max = effectiveMaxBranches(tier, status);
|
||||
if (max === null) return null; // unlimited
|
||||
const count = await Branch.countDocuments({ businessId });
|
||||
const count = await Branch.countDocuments({ businessId, isActive: { $ne: false } });
|
||||
if (count >= max) {
|
||||
return `Your ${TIERS[tier].name} plan allows ${max} branch${max === 1 ? "" : "es"}. Upgrade to add more.`;
|
||||
const plan = status === "TRIALING" ? "free trial" : `${TIERS[tier].name} plan`;
|
||||
return `Your ${plan} allows ${max} active branch${max === 1 ? "" : "es"}. Upgrade to add more.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,17 @@ import type { AuthUser, Product } from "@/lib/types";
|
||||
|
||||
const KEY = "aquaflow_terminal_session";
|
||||
|
||||
/** The branch's stock as the server last reported it. */
|
||||
export interface StockSnapshot {
|
||||
byProduct: Record<string, number>;
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export interface SessionSnapshot {
|
||||
user: AuthUser;
|
||||
features: { mpesa: boolean };
|
||||
products: Product[];
|
||||
stock?: StockSnapshot;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
@@ -32,6 +39,7 @@ export function saveSession(update: Partial<SessionSnapshot> & { user: AuthUser
|
||||
const next: SessionSnapshot = {
|
||||
features: update.features ?? (sameUser ? prev!.features : { mpesa: false }),
|
||||
products: update.products ?? (sameUser ? prev!.products : []),
|
||||
stock: update.stock ?? (sameUser ? prev!.stock : undefined),
|
||||
user: update.user,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -66,6 +66,19 @@ export const DEFAULT_TIER: TierId = "STARTER";
|
||||
// Days of free trial a brand-new business gets on signup.
|
||||
export const TRIAL_DAYS = 14;
|
||||
|
||||
// Branches allowed while a business is TRIALING, whatever its tier, so a
|
||||
// new owner can try the multi-branch features (transfers, per-branch
|
||||
// stock and reports) before paying. After the trial the tier's own limit
|
||||
// applies to adding branches; existing branches are never removed.
|
||||
export const TRIAL_MAX_BRANCHES = 2;
|
||||
|
||||
/** Branch limit for a business right now (null = unlimited). */
|
||||
export function effectiveMaxBranches(tier: TierId, status: SubscriptionStatus): number | null {
|
||||
const max = TIERS[tier].maxBranches;
|
||||
if (status !== "TRIALING" || max === null) return max;
|
||||
return Math.max(max, TRIAL_MAX_BRANCHES);
|
||||
}
|
||||
|
||||
export type SubscriptionStatus = "TRIALING" | "ACTIVE" | "PAST_DUE" | "SUSPENDED";
|
||||
|
||||
export function formatKsh(amount: number): string {
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface Branch {
|
||||
branchName: string;
|
||||
locationCity: string;
|
||||
managerId: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
@@ -94,6 +95,7 @@ export interface StockLevel {
|
||||
quantity: number;
|
||||
lowStockThreshold: number;
|
||||
status: "OK" | "LOW" | "OUT";
|
||||
productActive: boolean;
|
||||
}
|
||||
|
||||
export interface StockMovementRow {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Units a product can be measured in. Water refills are sized in litres;
|
||||
// side items (eggs, bottles, bags) are counted.
|
||||
export const PRODUCT_UNITS = ["L", "ml", "kg", "g", "pcs", "tray", "pack"] as const;
|
||||
export type ProductUnit = (typeof PRODUCT_UNITS)[number];
|
||||
|
||||
export const UNIT_LABELS: Record<ProductUnit, string> = {
|
||||
L: "litres",
|
||||
ml: "millilitres",
|
||||
kg: "kilograms",
|
||||
g: "grams",
|
||||
pcs: "pieces",
|
||||
tray: "trays",
|
||||
pack: "packs",
|
||||
};
|
||||
|
||||
/** "20L", "500ml", "30 pcs", "1 tray". */
|
||||
export function formatSize(size: number, unit: string | undefined): string {
|
||||
const u = unit || "L";
|
||||
return u === "L" || u === "ml" || u === "kg" || u === "g" ? `${size}${u}` : `${size} ${u}`;
|
||||
}
|
||||
Reference in New Issue
Block a user