Files
Joshua OosthuizenandClaude Sonnet 5 b137176c40 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
2026-09-04 10:35:04 +02:00

349 lines
14 KiB
TypeScript

import { PrismaClient, GLAccountType, DebitCredit, ControlAccountType, JournalType } from '@prisma/client';
const prisma = new PrismaClient();
const PERMISSIONS = [
'admin.segments.manage',
'admin.fiscalPeriods.manage',
'admin.users.manage',
'admin.tax.manage',
'gl.account.manage',
'gl.journal.manage',
'gl.journal.post',
'ar.customer.manage',
'ar.invoice.manage',
'ar.creditnote.manage',
'ar.receipt.manage',
'ap.supplier.manage',
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
'stock.warehouse.manage',
'stock.item.manage',
] as const;
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
ADMIN: PERMISSIONS,
ACCOUNTANT: [
'gl.account.manage',
'gl.journal.manage',
'gl.journal.post',
'admin.tax.manage',
'ar.customer.manage',
'ar.invoice.manage',
'ar.creditnote.manage',
'ar.receipt.manage',
'ap.supplier.manage',
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
'stock.warehouse.manage',
'stock.item.manage',
],
AR_AP_CLERK: [
'ar.customer.manage',
'ar.invoice.manage',
'ar.creditnote.manage',
'ar.receipt.manage',
'ap.supplier.manage',
'ap.invoice.manage',
'ap.debitnote.manage',
'ap.payment.manage',
],
STOCK_CLERK: ['stock.warehouse.manage', 'stock.item.manage'],
BRANCH_USER: [],
READ_ONLY: [],
};
async function main() {
console.log('Seeding company settings...');
await prisma.companySettings.upsert({
where: { id: 1 },
create: { id: 1, companyName: 'Demo Company', baseCurrencyCode: 'ZAR', fiscalYearStartMonth: 1, currentFiscalYear: 2026 },
update: {},
});
console.log('Seeding permissions and roles...');
for (const key of PERMISSIONS) {
await prisma.permission.upsert({ where: { key }, create: { key }, update: {} });
}
for (const roleName of Object.keys(ROLE_PERMISSIONS)) {
await prisma.role.upsert({ where: { name: roleName }, create: { name: roleName }, update: {} });
}
for (const [roleName, permissionKeys] of Object.entries(ROLE_PERMISSIONS)) {
const role = await prisma.role.findUniqueOrThrow({ where: { name: roleName } });
for (const key of permissionKeys) {
const permission = await prisma.permission.findUniqueOrThrow({ where: { key } });
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: role.id, permissionId: permission.id } },
create: { roleId: role.id, permissionId: permission.id },
update: {},
});
}
}
console.log('Seeding default account segment structure...');
const segmentDefs = [
{ segmentKey: 'MASTER', label: 'Master Account', order: 0, length: 4, format: 'NUMERIC' as const, isBranchSegment: false },
{ segmentKey: 'SUB', label: 'Sub Account', order: 1, length: 3, format: 'NUMERIC' as const, isBranchSegment: false },
{ segmentKey: 'SUBSUB', label: 'Sub-Sub Account', order: 2, length: 3, format: 'NUMERIC' as const, isBranchSegment: false },
{ segmentKey: 'BRANCH', label: 'Branch', order: 3, length: 3, format: 'ALPHA' as const, isBranchSegment: true },
];
const defByKey: Record<string, { id: number }> = {};
for (const def of segmentDefs) {
defByKey[def.segmentKey] = await prisma.accountSegmentDefinition.upsert({
where: { segmentKey: def.segmentKey },
create: def,
update: def,
});
}
const masterValues = [
{ code: '1000', description: 'Bank / Cash' },
{ 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) {
masterByCode[value.code] = await prisma.accountSegmentValue.upsert({
where: { segmentDefId_code: { segmentDefId: defByKey.MASTER.id, code: value.code } },
create: { segmentDefId: defByKey.MASTER.id, ...value },
update: value,
});
}
const subDefault = await prisma.accountSegmentValue.upsert({
where: { segmentDefId_code: { segmentDefId: defByKey.SUB.id, code: '000' } },
create: { segmentDefId: defByKey.SUB.id, code: '000', description: 'Default' },
update: {},
});
const subsubDefault = await prisma.accountSegmentValue.upsert({
where: { segmentDefId_code: { segmentDefId: defByKey.SUBSUB.id, code: '000' } },
create: { segmentDefId: defByKey.SUBSUB.id, code: '000', description: 'Default' },
update: {},
});
const branchDefs = [
{ code: 'HQ', description: 'Head Office' },
{ code: 'AMA', description: 'Branch AMA (example branch)' },
];
const branchByCode: Record<string, { id: number }> = {};
for (const branch of branchDefs) {
const value = await prisma.accountSegmentValue.upsert({
where: { segmentDefId_code: { segmentDefId: defByKey.BRANCH.id, code: branch.code } },
create: { segmentDefId: defByKey.BRANCH.id, code: branch.code, description: branch.description },
update: {},
});
branchByCode[branch.code] = value;
await prisma.branchProfile.upsert({
where: { segmentValueId: value.id },
create: { segmentValueId: value.id },
update: {},
});
}
console.log('Seeding default Chart of Accounts...');
const operationalAccounts: Array<{
masterCode: string;
accountName: string;
accountType: GLAccountType;
normalBalance: DebitCredit;
isControlAccount?: boolean;
controlType?: ControlAccountType;
}> = [
{ 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' },
// 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)) {
for (const account of operationalAccounts) {
const accountCode = [masterByCode[account.masterCode].id, subDefault.id, subsubDefault.id, branchByCode[branchCode].id];
const fullCode = `${account.masterCode}-000-000-${branchCode}`;
await prisma.gLAccount.upsert({
where: { accountCode: fullCode },
create: {
accountCode: fullCode,
accountName: `${account.accountName} — ${branchCode}`,
accountType: account.accountType,
normalBalance: account.normalBalance,
isControlAccount: account.isControlAccount ?? false,
controlType: account.controlType,
segmentValues: {
create: [
{ segmentDefId: defByKey.MASTER.id, segmentValueId: accountCode[0] },
{ segmentDefId: defByKey.SUB.id, segmentValueId: accountCode[1] },
{ segmentDefId: defByKey.SUBSUB.id, segmentValueId: accountCode[2] },
{ segmentDefId: defByKey.BRANCH.id, segmentValueId: accountCode[3] },
],
},
},
update: {
accountName: `${account.accountName} — ${branchCode}`,
accountType: account.accountType,
normalBalance: account.normalBalance,
isControlAccount: account.isControlAccount ?? false,
controlType: account.controlType ?? null,
},
});
}
}
// Equity accounts are booked at Head Office only.
const equityAccounts: Array<{ masterCode: string; accountName: string; controlType?: ControlAccountType }> = [
{ masterCode: '3000', accountName: 'Retained Earnings', controlType: 'RETAINED_EARNINGS' },
{ masterCode: '3100', accountName: 'Share Capital' },
];
for (const account of equityAccounts) {
const fullCode = `${account.masterCode}-000-000-HQ`;
await prisma.gLAccount.upsert({
where: { accountCode: fullCode },
create: {
accountCode: fullCode,
accountName: account.accountName,
accountType: 'EQUITY',
normalBalance: 'CREDIT',
controlType: account.controlType,
segmentValues: {
create: [
{ segmentDefId: defByKey.MASTER.id, segmentValueId: masterByCode[account.masterCode].id },
{ segmentDefId: defByKey.SUB.id, segmentValueId: subDefault.id },
{ segmentDefId: defByKey.SUBSUB.id, segmentValueId: subsubDefault.id },
{ segmentDefId: defByKey.BRANCH.id, segmentValueId: branchByCode.HQ.id },
],
},
},
update: { accountName: account.accountName, controlType: account.controlType ?? null },
});
}
console.log('Seeding journals...');
await prisma.journal.upsert({
where: { code: 'GJ' },
create: { code: 'GJ', name: 'General Journal', journalType: JournalType.GENERAL },
update: {},
});
await prisma.journal.upsert({
where: { code: 'PC-HQ' },
create: {
code: 'PC-HQ',
name: 'Petty Cash — Head Office',
journalType: JournalType.CUSTOM,
defaultBranchSegmentValueId: branchByCode.HQ.id,
},
update: {},
});
await prisma.journal.upsert({
where: { code: 'SALES' },
create: { code: 'SALES', name: 'Sales Journal', journalType: JournalType.SALES },
update: {},
});
await prisma.journal.upsert({
where: { code: 'CASH_RECEIPT' },
create: { code: 'CASH_RECEIPT', name: 'Cash Receipts Journal', journalType: JournalType.CASH_RECEIPT },
update: {},
});
await prisma.journal.upsert({
where: { code: 'PURCHASES' },
create: { code: 'PURCHASES', name: 'Purchases Journal', journalType: JournalType.PURCHASES },
update: {},
});
await prisma.journal.upsert({
where: { code: 'CASH_PAYMENT' },
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 = [
{ code: 'STD', name: 'Standard Rate (15%)', ratePercent: 15 },
{ code: 'ZERO', name: 'Zero-Rated (0%)', ratePercent: 0 },
];
for (const taxCode of taxCodes) {
await prisma.taxCode.upsert({ where: { code: taxCode.code }, create: taxCode, update: taxCode });
}
console.log('Seeding current fiscal year...');
const fiscalYear = await prisma.fiscalYear.upsert({
where: { year: 2026 },
create: { year: 2026, startDate: new Date(Date.UTC(2026, 0, 1)), endDate: new Date(Date.UTC(2026, 11, 31)) },
update: {},
});
for (let month = 0; month < 12; month += 1) {
const startDate = new Date(Date.UTC(2026, month, 1));
const endDate = new Date(Date.UTC(2026, month + 1, 0));
await prisma.fiscalPeriod.upsert({
where: { fiscalYearId_periodNumber: { fiscalYearId: fiscalYear.id, periodNumber: month + 1 } },
create: { fiscalYearId: fiscalYear.id, periodNumber: month + 1, startDate, endDate },
update: {},
});
}
console.log('Seeding admin user...');
// No password here — identity is established by the Account Manager's handoff token and
// matched to this User by email (see src/modules/auth/auth.service.ts verifyHandoff()).
// This email must match the Person who logs into the Manager for the mapping to resolve.
const adminEmail = 'joosth2002@icloud.com';
const adminRole = await prisma.role.findUniqueOrThrow({ where: { name: 'ADMIN' } });
const existingAdmin = await prisma.user.findUnique({ where: { email: adminEmail } });
if (!existingAdmin) {
await prisma.user.create({
data: {
email: adminEmail,
fullName: 'System Administrator',
roles: { create: { roleId: adminRole.id } },
},
});
console.log(`Created admin user ${adminEmail} — signs in via the Account Manager, no local password.`);
}
console.log('Seed complete.');
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});