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
349 lines
14 KiB
TypeScript
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();
|
|
});
|