Add Stock catalog (items/warehouses) and fix session persistence

Stock is scoped to a catalog + cost centers only, per direction that
physical GRV/stock-take/recipe transactions belong in an external
system (Matrix), not this accounting app. Stock Control/GRN/Variance/
PPV GL accounts are now plain postable accounts rather than locked
control accounts.

Also fixes session loss on page refresh: auth stores now persist to
localStorage, and ProtectedRoute waits for rehydration before deciding
to redirect to login.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWqiUSojMLUoN7mrJqdEbN
This commit is contained in:
Joshua Oosthuizen
2026-09-04 10:35:04 +02:00
co-authored by Claude Sonnet 5
parent 4acbc03b35
commit b137176c40
35 changed files with 1199 additions and 46 deletions
@@ -0,0 +1,285 @@
-- CreateEnum
CREATE TYPE "CostingMethod" AS ENUM ('MANUAL', 'LATEST', 'MOVING_AVERAGE');
-- CreateEnum
CREATE TYPE "StockMovementType" AS ENUM ('RECEIPT', 'ISSUE', 'TRANSFER_IN', 'TRANSFER_OUT', 'CONSUMPTION', 'PRODUCTION_IN', 'PRODUCTION_OUT', 'ADJUSTMENT_IN', 'ADJUSTMENT_OUT');
-- CreateEnum
CREATE TYPE "StockDocumentStatus" AS ENUM ('DRAFT', 'POSTED', 'VOID');
-- AlterEnum
ALTER TYPE "ControlAccountType" ADD VALUE 'GRN';
-- DropForeignKey
ALTER TABLE "APInvoiceLine" DROP CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey";
-- AlterTable
ALTER TABLE "APInvoiceLine" ADD COLUMN "grvLineId" INTEGER,
ALTER COLUMN "expenseGLAccountId" DROP NOT NULL;
-- CreateTable
CREATE TABLE "Warehouse" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Warehouse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockItem" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"costingMethod" "CostingMethod" NOT NULL DEFAULT 'MOVING_AVERAGE',
"manualCost" DECIMAL(18,4),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockLevel" (
"id" SERIAL NOT NULL,
"itemId" INTEGER NOT NULL,
"warehouseId" INTEGER NOT NULL,
"quantityOnHand" DECIMAL(18,4) NOT NULL DEFAULT 0,
"averageCost" DECIMAL(18,4) NOT NULL DEFAULT 0,
"lastCost" DECIMAL(18,4) NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StockLevel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockMovement" (
"id" SERIAL NOT NULL,
"itemId" INTEGER NOT NULL,
"warehouseId" INTEGER NOT NULL,
"movementType" "StockMovementType" NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL,
"unitCost" DECIMAL(18,4) NOT NULL,
"totalCost" DECIMAL(18,2) NOT NULL,
"movementDate" TIMESTAMP(3) NOT NULL,
"description" TEXT,
"branchSegmentValueId" INTEGER NOT NULL,
"sourceType" TEXT,
"sourceId" INTEGER,
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Recipe" (
"id" SERIAL NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"outputItemId" INTEGER NOT NULL,
"yieldQuantity" DECIMAL(18,4) NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Recipe_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RecipeComponent" (
"id" SERIAL NOT NULL,
"recipeId" INTEGER NOT NULL,
"componentItemId" INTEGER NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL,
CONSTRAINT "RecipeComponent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockAdjustment" (
"id" SERIAL NOT NULL,
"adjustmentNumber" TEXT NOT NULL,
"warehouseId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"adjustmentDate" TIMESTAMP(3) NOT NULL,
"reference" TEXT,
"status" "StockDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockAdjustment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockAdjustmentLine" (
"id" SERIAL NOT NULL,
"adjustmentId" INTEGER NOT NULL,
"itemId" INTEGER NOT NULL,
"systemQuantity" DECIMAL(18,4) NOT NULL,
"countedQuantity" DECIMAL(18,4) NOT NULL,
"unitCost" DECIMAL(18,4) NOT NULL,
"varianceValue" DECIMAL(18,2) NOT NULL,
CONSTRAINT "StockAdjustmentLine_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GoodsReceivedVoucher" (
"id" SERIAL NOT NULL,
"grvNumber" TEXT NOT NULL,
"supplierId" INTEGER NOT NULL,
"warehouseId" INTEGER NOT NULL,
"branchSegmentValueId" INTEGER NOT NULL,
"grvDate" TIMESTAMP(3) NOT NULL,
"reference" TEXT,
"status" "StockDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"journalEntryId" INTEGER,
"createdById" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GoodsReceivedVoucher_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GoodsReceivedVoucherLine" (
"id" SERIAL NOT NULL,
"grvId" INTEGER NOT NULL,
"itemId" INTEGER NOT NULL,
"quantity" DECIMAL(18,4) NOT NULL,
"unitPrice" DECIMAL(18,4) NOT NULL,
"lineTotal" DECIMAL(18,2) NOT NULL,
"matchedQuantity" DECIMAL(18,4) NOT NULL DEFAULT 0,
CONSTRAINT "GoodsReceivedVoucherLine_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Warehouse_code_key" ON "Warehouse"("code");
-- CreateIndex
CREATE INDEX "Warehouse_branchSegmentValueId_idx" ON "Warehouse"("branchSegmentValueId");
-- CreateIndex
CREATE UNIQUE INDEX "StockItem_code_key" ON "StockItem"("code");
-- CreateIndex
CREATE UNIQUE INDEX "StockLevel_itemId_warehouseId_key" ON "StockLevel"("itemId", "warehouseId");
-- CreateIndex
CREATE INDEX "StockMovement_itemId_warehouseId_idx" ON "StockMovement"("itemId", "warehouseId");
-- CreateIndex
CREATE INDEX "StockMovement_sourceType_sourceId_idx" ON "StockMovement"("sourceType", "sourceId");
-- CreateIndex
CREATE UNIQUE INDEX "Recipe_code_key" ON "Recipe"("code");
-- CreateIndex
CREATE UNIQUE INDEX "Recipe_outputItemId_key" ON "Recipe"("outputItemId");
-- CreateIndex
CREATE INDEX "RecipeComponent_recipeId_idx" ON "RecipeComponent"("recipeId");
-- CreateIndex
CREATE UNIQUE INDEX "StockAdjustment_adjustmentNumber_key" ON "StockAdjustment"("adjustmentNumber");
-- CreateIndex
CREATE INDEX "StockAdjustment_warehouseId_idx" ON "StockAdjustment"("warehouseId");
-- CreateIndex
CREATE INDEX "StockAdjustment_branchSegmentValueId_idx" ON "StockAdjustment"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "StockAdjustment_status_idx" ON "StockAdjustment"("status");
-- CreateIndex
CREATE INDEX "StockAdjustmentLine_adjustmentId_idx" ON "StockAdjustmentLine"("adjustmentId");
-- CreateIndex
CREATE UNIQUE INDEX "GoodsReceivedVoucher_grvNumber_key" ON "GoodsReceivedVoucher"("grvNumber");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucher_supplierId_idx" ON "GoodsReceivedVoucher"("supplierId");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucher_warehouseId_idx" ON "GoodsReceivedVoucher"("warehouseId");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucher_branchSegmentValueId_idx" ON "GoodsReceivedVoucher"("branchSegmentValueId");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucher_status_idx" ON "GoodsReceivedVoucher"("status");
-- CreateIndex
CREATE INDEX "GoodsReceivedVoucherLine_grvId_idx" ON "GoodsReceivedVoucherLine"("grvId");
-- CreateIndex
CREATE INDEX "APInvoiceLine_grvLineId_idx" ON "APInvoiceLine"("grvLineId");
-- AddForeignKey
ALTER TABLE "JournalEntryLine" ADD CONSTRAINT "JournalEntryLine_stockMovementId_fkey" FOREIGN KEY ("stockMovementId") REFERENCES "StockMovement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey" FOREIGN KEY ("expenseGLAccountId") REFERENCES "GLAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_grvLineId_fkey" FOREIGN KEY ("grvLineId") REFERENCES "GoodsReceivedVoucherLine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Warehouse" ADD CONSTRAINT "Warehouse_branchSegmentValueId_fkey" FOREIGN KEY ("branchSegmentValueId") REFERENCES "AccountSegmentValue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockLevel" ADD CONSTRAINT "StockLevel_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockLevel" ADD CONSTRAINT "StockLevel_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Recipe" ADD CONSTRAINT "Recipe_outputItemId_fkey" FOREIGN KEY ("outputItemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RecipeComponent" ADD CONSTRAINT "RecipeComponent_recipeId_fkey" FOREIGN KEY ("recipeId") REFERENCES "Recipe"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RecipeComponent" ADD CONSTRAINT "RecipeComponent_componentItemId_fkey" FOREIGN KEY ("componentItemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustmentLine" ADD CONSTRAINT "StockAdjustmentLine_adjustmentId_fkey" FOREIGN KEY ("adjustmentId") REFERENCES "StockAdjustment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustmentLine" ADD CONSTRAINT "StockAdjustmentLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucher" ADD CONSTRAINT "GoodsReceivedVoucher_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Supplier"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucher" ADD CONSTRAINT "GoodsReceivedVoucher_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucher" ADD CONSTRAINT "GoodsReceivedVoucher_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucherLine" ADD CONSTRAINT "GoodsReceivedVoucherLine_grvId_fkey" FOREIGN KEY ("grvId") REFERENCES "GoodsReceivedVoucher"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GoodsReceivedVoucherLine" ADD CONSTRAINT "GoodsReceivedVoucherLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "StockItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "ControlAccountType" ADD VALUE 'STOCK_VARIANCE';
ALTER TYPE "ControlAccountType" ADD VALUE 'PURCHASE_PRICE_VARIANCE';
@@ -0,0 +1,96 @@
-- Simplifies Stock down to a catalog (StockItem) + cost centers (Warehouse) + a SOH view
-- (StockLevel) for a future Matrix feed to populate. Removes the GRV / Stock Take / Recipe
-- production transactional workflows entirely — physical stock transactions belong in Matrix,
-- not in this accounting system.
-- Data cleanup before structural changes (dev/test fixtures from the removed GRV-matching flow).
DELETE FROM "APInvoiceLine" WHERE "expenseGLAccountId" IS NULL;
-- Demote the Stock-related control accounts (1200 Stock, 2050 GRN Accrual, 5200 Stock Variance,
-- 5300 Purchase Price Variance) back to plain, manually-postable accounts rather than deleting
-- them — several already have real posted JournalEntryLine rows against them from testing, and
-- posted ledger history is never deleted. With the GRV/Stock-Take/Recipe workflows gone, nothing
-- else in this app owns these accounts, and leaving them locked as control accounts would make
-- them permanently unpostable by anyone.
UPDATE "GLAccount" SET "isControlAccount" = false, "controlType" = NULL
WHERE "controlType" IN ('STOCK', 'GRN', 'STOCK_VARIANCE', 'PURCHASE_PRICE_VARIANCE');
-- AlterEnum
BEGIN;
CREATE TYPE "ControlAccountType_new" AS ENUM ('AR', 'AP', 'TAX_OUTPUT', 'TAX_INPUT', 'BANK', 'RETAINED_EARNINGS');
ALTER TABLE "GLAccount" ALTER COLUMN "controlType" TYPE "ControlAccountType_new" USING ("controlType"::text::"ControlAccountType_new");
ALTER TYPE "ControlAccountType" RENAME TO "ControlAccountType_old";
ALTER TYPE "ControlAccountType_new" RENAME TO "ControlAccountType";
DROP TYPE "ControlAccountType_old";
COMMIT;
-- DropForeignKey
ALTER TABLE "APInvoiceLine" DROP CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey";
ALTER TABLE "APInvoiceLine" DROP CONSTRAINT "APInvoiceLine_grvLineId_fkey";
ALTER TABLE "GoodsReceivedVoucher" DROP CONSTRAINT "GoodsReceivedVoucher_createdById_fkey";
ALTER TABLE "GoodsReceivedVoucher" DROP CONSTRAINT "GoodsReceivedVoucher_supplierId_fkey";
ALTER TABLE "GoodsReceivedVoucher" DROP CONSTRAINT "GoodsReceivedVoucher_warehouseId_fkey";
ALTER TABLE "GoodsReceivedVoucherLine" DROP CONSTRAINT "GoodsReceivedVoucherLine_grvId_fkey";
ALTER TABLE "GoodsReceivedVoucherLine" DROP CONSTRAINT "GoodsReceivedVoucherLine_itemId_fkey";
ALTER TABLE "JournalEntryLine" DROP CONSTRAINT "JournalEntryLine_stockMovementId_fkey";
ALTER TABLE "Recipe" DROP CONSTRAINT "Recipe_outputItemId_fkey";
ALTER TABLE "RecipeComponent" DROP CONSTRAINT "RecipeComponent_componentItemId_fkey";
ALTER TABLE "RecipeComponent" DROP CONSTRAINT "RecipeComponent_recipeId_fkey";
ALTER TABLE "StockAdjustment" DROP CONSTRAINT "StockAdjustment_createdById_fkey";
ALTER TABLE "StockAdjustment" DROP CONSTRAINT "StockAdjustment_warehouseId_fkey";
ALTER TABLE "StockAdjustmentLine" DROP CONSTRAINT "StockAdjustmentLine_adjustmentId_fkey";
ALTER TABLE "StockAdjustmentLine" DROP CONSTRAINT "StockAdjustmentLine_itemId_fkey";
ALTER TABLE "StockMovement" DROP CONSTRAINT "StockMovement_createdById_fkey";
ALTER TABLE "StockMovement" DROP CONSTRAINT "StockMovement_itemId_fkey";
ALTER TABLE "StockMovement" DROP CONSTRAINT "StockMovement_warehouseId_fkey";
-- DropIndex
DROP INDEX "APInvoiceLine_grvLineId_idx";
-- AlterTable
ALTER TABLE "APInvoiceLine" DROP COLUMN "grvLineId",
ALTER COLUMN "expenseGLAccountId" SET NOT NULL;
-- AlterTable
ALTER TABLE "JournalEntryLine" DROP COLUMN "stockMovementId";
-- AlterTable
ALTER TABLE "StockItem" DROP COLUMN "costingMethod",
DROP COLUMN "manualCost";
-- AlterTable
ALTER TABLE "StockLevel" DROP COLUMN "averageCost",
DROP COLUMN "lastCost";
-- DropTable
DROP TABLE "GoodsReceivedVoucher";
-- DropTable
DROP TABLE "GoodsReceivedVoucherLine";
-- DropTable
DROP TABLE "Recipe";
-- DropTable
DROP TABLE "RecipeComponent";
-- DropTable
DROP TABLE "StockAdjustment";
-- DropTable
DROP TABLE "StockAdjustmentLine";
-- DropTable
DROP TABLE "StockMovement";
-- DropEnum
DROP TYPE "CostingMethod";
-- DropEnum
DROP TYPE "StockDocumentStatus";
-- DropEnum
DROP TYPE "StockMovementType";
-- AddForeignKey
ALTER TABLE "APInvoiceLine" ADD CONSTRAINT "APInvoiceLine_expenseGLAccountId_fkey" FOREIGN KEY ("expenseGLAccountId") REFERENCES "GLAccount"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+48 -4
View File
@@ -105,6 +105,7 @@ model AccountSegmentValue {
glAccountLinks GLAccountSegmentValue[]
customers Customer[]
suppliers Supplier[]
warehouses Warehouse[]
@@unique([segmentDefId, code])
}
@@ -134,7 +135,6 @@ enum DebitCredit {
enum ControlAccountType {
AR
AP
STOCK
TAX_OUTPUT
TAX_INPUT
BANK
@@ -253,13 +253,11 @@ model JournalEntryLine {
credit Decimal @default(0) @db.Decimal(18, 2)
description String?
branchSegmentValueId Int?
// Sub-ledger cross-references for drill-down. arInvoiceId/apInvoiceId are real FKs now that
// AR/AP exist; stockMovementId stays a plain id until Stock lands in its own phase.
// Sub-ledger cross-references for drill-down.
arInvoiceId Int?
arInvoice ARInvoice? @relation(fields: [arInvoiceId], references: [id])
apInvoiceId Int?
apInvoice APInvoice? @relation(fields: [apInvoiceId], references: [id])
stockMovementId Int?
currencyCode String @default("BASE")
exchangeRateToBase Decimal @default(1) @db.Decimal(18, 6)
@@ -615,6 +613,52 @@ model APAllocation {
@@index([debitNoteId])
}
// ---------------------------------------------------------------------------
// Stock (catalog + cost centers only — see the comment on Warehouse below)
// ---------------------------------------------------------------------------
// Deliberately minimal: this is accounting software, not a stock/warehouse system. Physical
// stock transactions (goods received, stock takes, recipe production) belong in Matrix (the
// external hospitality/retail stock system this app is designed to eventually integrate with)
// — this app only needs a catalog of items, a set of cost centers to view SOH by, and a place
// for whatever quantities Matrix eventually reports. No costing engine, no movement ledger, no
// document workflow live here.
model Warehouse {
id Int @id @default(autoincrement())
code String @unique
name String
branchSegmentValueId Int
branchSegmentValue AccountSegmentValue @relation(fields: [branchSegmentValueId], references: [id])
isActive Boolean @default(true)
stockLevels StockLevel[]
@@index([branchSegmentValueId])
}
model StockItem {
id Int @id @default(autoincrement())
code String @unique
name String
unitOfMeasure String
isActive Boolean @default(true)
stockLevels StockLevel[]
createdAt DateTime @default(now())
}
// Quantity on hand per item/warehouse — a read target for a future Matrix feed, not written to
// from any UI in this app today.
model StockLevel {
id Int @id @default(autoincrement())
itemId Int
item StockItem @relation(fields: [itemId], references: [id])
warehouseId Int
warehouse Warehouse @relation(fields: [warehouseId], references: [id])
quantityOnHand Decimal @default(0) @db.Decimal(18, 4)
updatedAt DateTime @updatedAt
@@unique([itemId, warehouseId])
}
// ---------------------------------------------------------------------------
// Auth / RBAC
// ---------------------------------------------------------------------------
+36 -3
View File
@@ -18,6 +18,8 @@ const PERMISSIONS = [
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
'stock.warehouse.manage',
'stock.item.manage',
] as const;
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
@@ -35,6 +37,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
'stock.warehouse.manage',
'stock.item.manage',
],
AR_AP_CLERK: [
'ar.customer.manage',
@@ -46,6 +50,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
'ap.debitnote.manage',
'ap.payment.manage',
],
STOCK_CLERK: ['stock.warehouse.manage', 'stock.item.manage'],
BRANCH_USER: [],
READ_ONLY: [],
};
@@ -98,14 +103,17 @@ async function main() {
{ code: '1100', description: 'Accounts Receivable Control' },
{ code: '1200', description: 'Inventory / Stock Control' },
{ code: '2000', description: 'Accounts Payable Control' },
{ code: '2050', description: 'Goods Received Not Invoiced' },
{ code: '2100', description: 'Output Tax Payable' },
{ code: '2200', description: 'Input Tax Receivable' },
{ code: '2300', description: 'Accrued Expenses' },
{ code: '3000', description: 'Retained Earnings' },
{ code: '3100', description: 'Share Capital' },
{ code: '4000', description: 'Sales Income' },
{ code: '5000', description: 'Cost of Goods Sold' },
{ code: '5100', description: 'Operating Expenses' },
{ code: '5200', description: 'Stock Variance' },
{ code: '5300', description: 'Purchase Price Variance' },
];
const masterByCode: Record<string, { id: number }> = {};
for (const value of masterValues) {
@@ -157,14 +165,19 @@ async function main() {
}> = [
{ masterCode: '1000', accountName: 'Bank / Cash', accountType: 'ASSET', normalBalance: 'DEBIT', controlType: 'BANK' },
{ masterCode: '1100', accountName: 'Accounts Receivable Control', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'AR' },
{ masterCode: '1200', accountName: 'Inventory / Stock Control', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'STOCK' },
// Not a control account: no subledger in this app owns stock value — see the Stock section
// of prisma/schema.prisma. Postable directly via manual journal like any other GL account.
{ masterCode: '1200', accountName: 'Inventory / Stock Control', accountType: 'ASSET', normalBalance: 'DEBIT' },
{ masterCode: '2000', accountName: 'Accounts Payable Control', accountType: 'LIABILITY', normalBalance: 'CREDIT', isControlAccount: true, controlType: 'AP' },
{ masterCode: '2050', accountName: 'Goods Received Not Invoiced', accountType: 'LIABILITY', normalBalance: 'CREDIT' },
{ masterCode: '2100', accountName: 'Output Tax Payable', accountType: 'LIABILITY', normalBalance: 'CREDIT', isControlAccount: true, controlType: 'TAX_OUTPUT' },
{ masterCode: '2200', accountName: 'Input Tax Receivable', accountType: 'ASSET', normalBalance: 'DEBIT', isControlAccount: true, controlType: 'TAX_INPUT' },
{ masterCode: '2300', accountName: 'Accrued Expenses', accountType: 'LIABILITY', normalBalance: 'CREDIT' },
{ masterCode: '4000', accountName: 'Sales Income', accountType: 'INCOME', normalBalance: 'CREDIT' },
{ masterCode: '5000', accountName: 'Cost of Goods Sold', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
{ masterCode: '5100', accountName: 'Operating Expenses', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
{ masterCode: '5200', accountName: 'Stock Variance', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
{ masterCode: '5300', accountName: 'Purchase Price Variance', accountType: 'EXPENSE', normalBalance: 'DEBIT' },
];
for (const branchCode of Object.keys(branchByCode)) {
@@ -189,7 +202,13 @@ async function main() {
],
},
},
update: {},
update: {
accountName: `${account.accountName} — ${branchCode}`,
accountType: account.accountType,
normalBalance: account.normalBalance,
isControlAccount: account.isControlAccount ?? false,
controlType: account.controlType ?? null,
},
});
}
}
@@ -218,7 +237,7 @@ async function main() {
],
},
},
update: {},
update: { accountName: account.accountName, controlType: account.controlType ?? null },
});
}
@@ -258,6 +277,20 @@ async function main() {
create: { code: 'CASH_PAYMENT', name: 'Cash Payments Journal', journalType: JournalType.CASH_PAYMENT },
update: {},
});
await prisma.journal.upsert({
where: { code: 'STOCK' },
create: { code: 'STOCK', name: 'Stock Journal', journalType: JournalType.STOCK },
update: {},
});
console.log('Seeding warehouses...');
for (const branchCode of Object.keys(branchByCode)) {
await prisma.warehouse.upsert({
where: { code: `WH-${branchCode}` },
create: { code: `WH-${branchCode}`, name: `Main Warehouse — ${branchCode}`, branchSegmentValueId: branchByCode[branchCode].id },
update: {},
});
}
console.log('Seeding tax codes...');
const taxCodes = [