Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ RUN addgroup --system --gid 1001 nodejs && \
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/db/migrations ./db/migrations
COPY --from=builder /app/db ./db
COPY --from=builder /app/package.json ./
COPY --from=builder /app/drizzle.config.ts drizzle.config.ts
COPY --from=builder /app/scripts/run-migrations.ts scripts/run-migrations.ts
Expand Down
113 changes: 74 additions & 39 deletions api/accounts-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,44 @@ import { toLocalDateKey } from "./lib/date-key";
import { ensureSystemAccount } from "./lib/accounting-accounts";
import { validateOperationalAccountClassification } from "./lib/accounting-validation";

async function syncOperationalToCoaBalance(
db: any,
tx: any,
accountId: number
): Promise<void> {
const [account] = await tx.select().from(accounts).where(eq(accounts.id, accountId)).limit(1);
if (!account || account.businessId) return;

const [location] = await tx.select().from(locations).where(eq(locations.id, account.locationId)).limit(1);
if (!location?.businessId) return;

const businessId = location.businessId;
const balance = account.currentBalance || "0.00";

const typeToSystemKey: Record<string, string> = {
cash: "asset:cash",
mpesa: "asset:cash",
bank_account: "asset:bank",
};

const systemKey = typeToSystemKey[account.type];
if (!systemKey) return;

const coaAccount = await tx.query.accounts.findFirst({
where: and(
eq(accounts.businessId, businessId),
eq(accounts.systemKey, systemKey),
isNull(accounts.deletedAt)
),
});

if (coaAccount) {
await tx.update(accounts)
.set({ currentBalance: balance })
.where(eq(accounts.id, coaAccount.id));
}
}

export const drawingInputSchema = z.object({
accountId: z.number(),
amount: z.string(),
Expand Down Expand Up @@ -44,12 +82,11 @@ export const accountsRouter = createRouter({
const db = getDb();
const locIds = await getCurrentBusinessLocationIds(ctx);
if (locIds.length === 0) return [];
// Operational accounts: have locationId but NO accountType (pure operational, not COA)
return db.select().from(accounts).where(
and(
sql`${accounts.locationId} IN (${sql.join(locIds.map(id => sql`${id}`), sql`, `)})`,
isNull(accounts.deletedAt),
isNull(accounts.accountType) // Exclude COA accounts
isNull(accounts.accountType)
)
);
}),
Expand Down Expand Up @@ -103,7 +140,7 @@ export const accountsRouter = createRouter({
input.accountSubType,
);

await ensureSystemAccount({
const systemAccountId = await ensureSystemAccount({
businessId: location.businessId,
accountType: classification.accountType,
accountSubType: classification.accountSubType,
Expand All @@ -125,12 +162,14 @@ export const accountsRouter = createRouter({
openingBalance: ob,
currentBalance: ob,
isPaymentMethod: input.isPaymentMethod ?? false,
accountType: classification.accountType as any,
accountSubType: classification.accountSubType as any,
isContra: false,
}).returning();
const result = (rows as any[])[0];

if (input.accountType && parseFloat(ob) !== 0) {
await db.update(accounts).set({ currentBalance: ob }).where(eq(accounts.id, systemAccountId));
}

await logAudit({
userId,
businessId: location.businessId,
Expand All @@ -140,8 +179,6 @@ export const accountsRouter = createRouter({
details: {
locationId: input.locationId,
type: input.type,
accountType: classification.accountType,
accountSubType: classification.accountSubType,
isPaymentMethod: input.isPaymentMethod ?? false,
},
});
Expand All @@ -165,29 +202,25 @@ export const accountsRouter = createRouter({
const db = getDb();
const userId = (ctx as any).user?.id ?? 1;
const existing = await requireAuthorizedEntity(ctx, accounts, input.id);
const { id, ...updates } = input;

const normalizedUpdates: Record<string, unknown> = { ...updates };
if (input.accountType !== undefined || input.accountSubType !== undefined) {
const classification = validateOperationalAccountClassification(
existing.type as any,
input.accountType as any,
input.accountSubType,
);
normalizedUpdates.accountType = classification.accountType;
normalizedUpdates.accountSubType = classification.accountSubType;
normalizedUpdates.isContra = false;
}
const { id, accountType, accountSubType, isContra, ...rest } = input;

await db.update(accounts).set(normalizedUpdates).where(eq(accounts.id, id));
const updates: Record<string, unknown> = { ...rest };

await db.transaction(async (tx) => {
await tx.update(accounts).set(updates).where(eq(accounts.id, id));

if (accountType !== undefined) {
await syncOperationalToCoaBalance(db, tx, id);
}
});

await logAudit({
userId,
businessId: existing.businessId ?? undefined,
action: "UPDATE",
resource: "accounts",
resourceId: id,
details: normalizedUpdates,
details: updates,
});

return { success: true };
Expand Down Expand Up @@ -217,6 +250,7 @@ export const accountsRouter = createRouter({
createdBy: userId,
} as any).returning();
await tx.update(accounts).set({ currentBalance: input.newBalance }).where(eq(accounts.id, input.id));
await syncOperationalToCoaBalance(db, tx, input.id);
});

await logAudit({
Expand Down Expand Up @@ -253,6 +287,7 @@ export const accountsRouter = createRouter({
createdBy: userId,
} as any).returning();
await tx.update(accounts).set({ currentBalance: newBal.toFixed(2) }).where(eq(accounts.id, input.accountId));
await syncOperationalToCoaBalance(db, tx, input.accountId);
});

await logAudit({
Expand Down Expand Up @@ -289,6 +324,7 @@ export const accountsRouter = createRouter({
createdBy: userId,
} as any).returning();
await tx.update(accounts).set({ currentBalance: newBal.toFixed(2) }).where(eq(accounts.id, input.accountId));
await syncOperationalToCoaBalance(db, tx, input.accountId);
});
return { id: input.accountId, newBalance: newBal.toFixed(2), success: true };
}),
Expand All @@ -306,7 +342,6 @@ export const accountsRouter = createRouter({
if (fromOldBal.lt(totalOut)) throw new Error("Insufficient funds in source account");
const fromNewBal = fromOldBal.minus(totalOut);

// Validate all destination accounts belong to the active business before starting tx
const toAcctMap = new Map();
for (const to of input.toAccounts) {
const toAcct = await requireAuthorizedEntity(ctx, accounts, to.accountId);
Expand All @@ -328,6 +363,7 @@ export const accountsRouter = createRouter({
createdBy: userId,
} as any).returning();
await tx.update(accounts).set({ currentBalance: fromNewBal.toFixed(2) }).where(eq(accounts.id, input.fromAccountId));
await syncOperationalToCoaBalance(db, tx, input.fromAccountId);

for (const to of input.toAccounts) {
const toAcct = toAcctMap.get(to.accountId);
Expand All @@ -346,6 +382,7 @@ export const accountsRouter = createRouter({
createdBy: userId,
} as any).returning();
await tx.update(accounts).set({ currentBalance: toNewBal.toFixed(2) }).where(eq(accounts.id, to.accountId));
await syncOperationalToCoaBalance(db, tx, to.accountId);
results.push({ accountId: to.accountId, amount: to.amount, newBalance: toNewBal.toFixed(2) });
}
});
Expand Down Expand Up @@ -410,7 +447,7 @@ export const accountsRouter = createRouter({
sql`, `
)})`,
isNull(accounts.deletedAt),
isNull(accounts.accountType) // Exclude COA accounts
isNull(accounts.accountType)
)
)
.orderBy(asc(accounts.name));
Expand Down Expand Up @@ -502,33 +539,31 @@ export const accountsRouter = createRouter({

for (const account of scopedAccounts) {
const dayBalance = accountEntriesByDate.get(account.id)?.get(dateKey);
if (dayBalance !== undefined) {
if (dayBalance) {
currentBalances.set(account.id, dayBalance);
}
const latestBalance = currentBalances.get(account.id) ?? "0.00";
row[`account_${account.id}`] = Number(latestBalance);
if (account.type === "cash") {
cashTotal = cashTotal.plus(d(latestBalance));
} else if (account.type === "mpesa") {
mpesaTotal = mpesaTotal.plus(d(latestBalance));
} else {
bankTotal = bankTotal.plus(d(latestBalance));
}
const bal = d(currentBalances.get(account.id) || "0");
const colKey = `account_${account.id}`;
if (account.type === "cash") cashTotal = cashTotal.plus(bal);
else if (account.type === "bank_account") bankTotal = bankTotal.plus(bal);
else if (account.type === "mpesa") mpesaTotal = mpesaTotal.plus(bal);
row[colKey] = bal.toNumber();
}

row.cashTotal = Number(cashTotal.toFixed(2));
row.bankTotal = Number(bankTotal.toFixed(2));
row.mpesaTotal = Number(mpesaTotal.toFixed(2));
row.totalBalance = Number(cashTotal.plus(bankTotal).plus(mpesaTotal).toFixed(2));
row.cashTotal = cashTotal.toNumber();
row.bankTotal = bankTotal.toNumber();
row.mpesaTotal = mpesaTotal.toNumber();
row.totalBalance = cashTotal.plus(bankTotal).plus(mpesaTotal).toNumber();
series.push(row);

cursor.setDate(cursor.getDate() + 1);
}

return {
fromDate: toLocalDateKey(startDate),
toDate: toLocalDateKey(endDate),
accountMeta,
series,
accountMeta,
};
}),
});
6 changes: 3 additions & 3 deletions src/pages/Accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,11 @@ export function Accounts() {
</div>
<div className="flex items-center gap-2 pt-2">
<input type="checkbox" id="linkToCoa" checked={form.linkToCoa} onChange={e => setForm(p => ({ ...p, linkToCoa: e.target.checked }))} className="rounded" />
<Label htmlFor="linkToCoa" className="text-sm font-medium">Use chart-compatible asset classification</Label>
<Label htmlFor="linkToCoa" className="text-sm font-medium">Show in Chart of Accounts</Label>
</div>
{form.linkToCoa && (
<>
<p className="text-xs text-[#8D8A87]">The general Accounts page only supports cash-equivalent asset links. Use the Chart of Accounts page for advanced liability, equity, revenue, or expense setup.</p>
<p className="text-xs text-[#8D8A87]">The account balance will sync to the corresponding Chart of Accounts entry, making it visible in financial statements.</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"><Label>Account Type</Label>
<select value="asset" onChange={() => undefined} className="w-full rounded-lg border border-[#E8E0D8] px-3 py-2 text-sm" disabled>
Expand Down Expand Up @@ -577,7 +577,7 @@ export function Accounts() {
</div>
<div className="flex items-center gap-2 pt-2">
<input type="checkbox" id="editLinkToCoa" checked={editForm.linkToCoa} onChange={e => setEditForm(p => ({ ...p, linkToCoa: e.target.checked }))} className="rounded" />
<Label htmlFor="editLinkToCoa" className="text-sm font-medium">Use chart-compatible asset classification</Label>
<Label htmlFor="editLinkToCoa" className="text-sm font-medium">Show in Chart of Accounts</Label>
</div>
{editForm.linkToCoa && (
<>
Expand Down
60 changes: 46 additions & 14 deletions src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export default function Login() {
});
const [accountNameStatus, setAccountNameStatus] = useState<"idle" | "checking" | "available" | "taken" | "invalid">("idle");
const [accountNameMessage, setAccountNameMessage] = useState("");
const [lookupLoading, setLookupLoading] = useState(false);
const [lookupError, setLookupError] = useState<string | null>(null);

const utils = trpc.useUtils();

Expand All @@ -68,10 +70,13 @@ export default function Login() {
console.log("[Login] lookupAccount SUCCESS", data);
setFoundBusiness(data.business as Record<string, unknown> | null);
setMode("credentials");
setLookupLoading(false);
setLookupError(null);
},
onError: (err: { message?: string }) => {
onError: (err: { message?: string }, _, __) => {
console.error("[Login] lookupAccount ERROR:", err);
toast.error(err.message || "Account not found");
setLookupLoading(false);
setLookupError(err.message || "Account not found");
},
});

Expand Down Expand Up @@ -99,12 +104,6 @@ export default function Login() {
onError: (err: { message?: string }) => toast.error(err.message || "Registration failed"),
});

useEffect(() => {
if (accountId && accountId.length >= 2 && mode === "accountLookup") {
lookupMutation.mutate({ accountId });
}
}, [accountId, mode, lookupMutation]);

const checkAvailability = useCallback(async (name: string) => {
const cleaned = name.toUpperCase().replace(/[^A-Z0-9]/g, "");
if (cleaned.length < 2) {
Expand Down Expand Up @@ -247,15 +246,48 @@ export default function Login() {
<Label className="text-xs text-[#8D8A87]">Account ID</Label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#8D8A87]" />
<Input value={accountId} onChange={e => setAccountId(e.target.value.toUpperCase())} placeholder="e.g. GENIUS" className="font-mono uppercase pl-9" required />
<Input
value={accountId}
onChange={e => setAccountId(e.target.value.toUpperCase())}
placeholder="e.g. GENIUS"
className="font-mono uppercase pl-9"
required
/>
{lookupLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-[#C73E1D]" />
</div>
)}
{lookupError && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<X className="h-4 w-4 text-red-600" />
</div>
)}
</div>
<p className="mt-1 text-xs text-[#8D8A87]">
Your unique business identifier.
{accountId && <span className="ml-1 text-[#C73E1D]">{"-> "}<strong>{accountId.toLowerCase()}.finaflow.app</strong></span>}
</p>
{lookupError && (
<p className="mt-1.5 text-sm text-red-600 flex items-center gap-1">
<X className="h-3 w-3" /> {lookupError}
</p>
)}
{!lookupError && accountId && (
<p className="mt-1 text-xs text-[#8D8A87]">
Your unique business identifier.
{accountId && <span className="ml-1 text-[#C73E1D]">{"-> "}<strong>{accountId.toLowerCase()}.finaflow.app</strong></span>}
</p>
)}
</div>
<Button type="submit" className="w-full bg-[#C73E1D]" disabled={lookupMutation.isPending}>
<ArrowRight className="mr-2 h-4 w-4" />{lookupMutation.isPending ? "Looking up..." : "Continue"}
{lookupMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Searching for Account...
</>
) : (
<>
<ArrowRight className="mr-2 h-4 w-4" />
Continue
</>
)}
</Button>
<p className="text-center text-xs text-[#8D8A87]">
New here? <button type="button" onClick={() => goToSignup(setMode, setSignupForm, "standard")} className="font-medium text-[#C73E1D] underline">Create a new account</button>
Expand Down
Loading