integrated hub

This commit is contained in:
nathanAndrinoid 2026-06-04 13:05:50 +00:00
parent bfa90b7377
commit f5fa820530
20 changed files with 1344 additions and 917 deletions

View File

@ -1,30 +1,49 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build a publish directory: Linux binary (server) + _fresh for deployment. .env is not copied (publish dir is not recreated). # Build and deploy CyberHybridHub to /home/chh/publish:
set -e # - compiles bootstrap-serve.ts to publish/server
# - syncs _fresh/ into publish
# - preserves existing publish/.env (never overwritten from the repo)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PUBLISH_DIR="$(cd "$SCRIPT_DIR/.." && pwd)/publish" PUBLISH_DIR="/home/chh/publish"
SERVICE_NAME="cyberhybridhub"
COMPILE_TARGET="${COMPILE_TARGET:-x86_64-unknown-linux-gnu}"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
echo "Building Fresh app..." echo "Building Fresh app..."
deno task build deno task build
# So the compiled binary finds _fresh at runtime (not compile-time path) echo "Ensuring publish directory exists..."
echo "Patching server-entry to use FRESH_ROOT at runtime..."
sed -i 's|const root = join(import.meta.dirname, "..");|const root = Deno.env.get("FRESH_ROOT") ?? join(import.meta.dirname, "..");|' _fresh/server/server-entry.mjs
echo "Ensuring publish directory exists (parent of project)..."
mkdir -p "$PUBLISH_DIR" mkdir -p "$PUBLISH_DIR"
echo "Compiling Linux binary (server)..." if [[ -f "$PUBLISH_DIR/.env" ]]; then
deno compile -A --no-check --include ./_fresh/client --target x86_64-unknown-linux-gnu -o "$PUBLISH_DIR/server" bootstrap-serve.ts echo "Keeping existing $PUBLISH_DIR/.env"
else
echo "Warning: $PUBLISH_DIR/.env not found."
echo " Create it before starting the service (copy from .env.example and set production values)."
fi
echo "Copying _fresh into publish/..." echo "Compiling Linux binary to $PUBLISH_DIR/server.new ..."
# Full _fresh (server + client assets) so the binary finds favicon, static files, etc. deno compile -A --no-check \
cp -r _fresh "$PUBLISH_DIR/" --include ./_fresh/client \
--target "$COMPILE_TARGET" \
-o "$PUBLISH_DIR/server.new" \
bootstrap-serve.ts
echo "Syncing _fresh into $PUBLISH_DIR ..."
rm -rf "$PUBLISH_DIR/_fresh"
cp -a _fresh "$PUBLISH_DIR/_fresh"
echo "Patching publish _fresh for runtime FRESH_ROOT ..."
sed -i 's|const root = join(import.meta.dirname, "..");|const root = Deno.env.get("FRESH_ROOT") ?? join(import.meta.dirname, "..");|' \
"$PUBLISH_DIR/_fresh/server/server-entry.mjs"
echo "Installing new server binary..."
chmod +x "$PUBLISH_DIR/server.new"
mv -f "$PUBLISH_DIR/server.new" "$PUBLISH_DIR/server"
# Create systemd service file (install with: sudo cp ... /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now cyberhybridhub)
SERVICE_NAME="cyberhybridhub"
SERVICE_FILE="$PUBLISH_DIR/${SERVICE_NAME}.service" SERVICE_FILE="$PUBLISH_DIR/${SERVICE_NAME}.service"
cat > "$SERVICE_FILE" << EOF cat > "$SERVICE_FILE" << EOF
[Unit] [Unit]
@ -34,24 +53,40 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
WorkingDirectory=$PUBLISH_DIR WorkingDirectory=$PUBLISH_DIR
Environment=PORT=80 EnvironmentFile=-$PUBLISH_DIR/.env
ExecStart=$PUBLISH_DIR/server ExecStart=$PUBLISH_DIR/server
Restart=on-failure Restart=on-failure
# Binding to port 80 typically requires root; run 'sudo systemctl start $SERVICE_NAME' when installing RestartSec=5
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
chmod 644 "$SERVICE_FILE" chmod 644 "$SERVICE_FILE"
echo "Created $SERVICE_FILE" echo "Wrote $SERVICE_FILE"
echo "Installing and reinitializing systemd service..." if [[ "${SKIP_SYSTEMD:-0}" == "1" ]]; then
sudo cp "$SERVICE_FILE" "/etc/systemd/system/" echo "SKIP_SYSTEMD=1 — skipped systemd install/restart."
sudo systemctl daemon-reload echo " sudo cp $SERVICE_FILE /etc/systemd/system/"
sudo systemctl stop "$SERVICE_NAME" 2>/dev/null || true echo " sudo systemctl daemon-reload && sudo systemctl enable --now $SERVICE_NAME"
sudo systemctl enable --now "$SERVICE_NAME" elif sudo -n true 2>/dev/null; then
echo "Service $SERVICE_NAME is enabled and running (port 80)." echo "Installing and restarting systemd service..."
sudo cp "$SERVICE_FILE" "/etc/systemd/system/${SERVICE_NAME}.service"
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
sudo systemctl restart "$SERVICE_NAME"
echo "Done. Contents of $PUBLISH_DIR:" if sudo systemctl is-active --quiet "$SERVICE_NAME"; then
echo "Service $SERVICE_NAME is active."
else
echo "Service $SERVICE_NAME failed to start. Check: sudo journalctl -u $SERVICE_NAME -n 50 --no-pager"
exit 1
fi
else
echo "No passwordless sudo — binary deployed; restart the service manually:"
echo " sudo cp $SERVICE_FILE /etc/systemd/system/"
echo " sudo systemctl daemon-reload && sudo systemctl restart $SERVICE_NAME"
fi
echo "Done. Deployed to $PUBLISH_DIR:"
ls -la "$PUBLISH_DIR/" ls -la "$PUBLISH_DIR/"
ls -la "$PUBLISH_DIR/_fresh" 2>/dev/null || true ls -la "$PUBLISH_DIR/_fresh" 2>/dev/null || true

View File

@ -3,7 +3,6 @@
import { import {
decryptDataWithStoredPassword, decryptDataWithStoredPassword,
getDate, getDate,
getDay,
getEncryptionPassword, getEncryptionPassword,
getMonth, getMonth,
getYear, getYear,
@ -11,6 +10,7 @@ import {
setEncryptionPassword, setEncryptionPassword,
} from "../helpers.ts"; } from "../helpers.ts";
import { Income } from "../services/financhalServices.ts"; import { Income } from "../services/financhalServices.ts";
import type { MonthlyBalanceCache } from "../services/dailyBalanceCache.ts";
interface Obligation { interface Obligation {
id: string; id: string;
@ -32,6 +32,7 @@ interface FinancialCalendarProps {
oldBalance?: string; oldBalance?: string;
oldDate?: string; oldDate?: string;
userId?: string; userId?: string;
monthlyCache?: MonthlyBalanceCache | null;
} }
export function getDaysInBaseMonth(year: number, month: number) { export function getDaysInBaseMonth(year: number, month: number) {
@ -177,8 +178,16 @@ function financialCalendarReducer(
} }
export default function FinancialCalendar( export default function FinancialCalendar(
{ obligations, incomes, yearNum, monthNum, oldBalance, oldDate, userId }: {
FinancialCalendarProps, obligations,
incomes,
yearNum,
monthNum,
oldBalance,
oldDate,
userId,
monthlyCache,
}: FinancialCalendarProps,
) { ) {
// Theme classes are defined in styles.css and respond to theme mode + media. // Theme classes are defined in styles.css and respond to theme mode + media.
@ -392,12 +401,49 @@ export default function FinancialCalendar(
return { incomes: dayIncomes, obligations: dayObligations }; return { incomes: dayIncomes, obligations: dayObligations };
} }
// Helper: Compute running balance for each day after the set balance date // Helper: Get daily balances - use cache if available, otherwise compute as fallback
function getDailyBalances(): { function getDailyBalances(): {
dailyBalances: Record<number, number>; dailyBalances: Record<number, number>;
incomesByDay: Record<number, number[]>; incomesByDay: Record<number, number[]>;
obligationsByDay: Record<number, number[]>; obligationsByDay: Record<number, number[]>;
} { } {
const dailyBalances: Record<number, number> = {};
const incomesByDay: Record<number, number[]> = {};
const obligationsByDay: Record<number, number[]> = {};
// Always calculate daily changes from client-side decrypted data
for (let d = 1; d <= daysInThisMonth; d++) {
incomesByDay[d] = [];
obligationsByDay[d] = [];
}
state.processedIncomes.forEach((inc) => {
if (!inc.received_date) return;
const d = MakeNewDate(inc.received_date);
if (getYear(d) === yearNum && getMonth(d) === baseMonth) {
const day = getDate(d);
incomesByDay[day].push(inc.amount || 0);
}
});
state.processedObligations.forEach((ob) => {
if (!ob.due_date) return;
const d = MakeNewDate(ob.due_date);
if (getYear(d) === yearNum && getMonth(d) === baseMonth) {
const day = getDate(d);
obligationsByDay[day].push(ob.amount || 0);
}
});
// If we have cached data, use it for running balances
if (monthlyCache && monthlyCache.entries) {
for (const entry of monthlyCache.entries) {
const date = new Date(entry.date);
const day = date.getDate();
dailyBalances[day] = entry.balance;
}
} else {
// Fallback: Compute running balances from scratch
const start = MakeNewDate(state.formDate); const start = MakeNewDate(state.formDate);
const startYear = getYear(start); const startYear = getYear(start);
const startMonth = getMonth(start); const startMonth = getMonth(start);
@ -405,15 +451,7 @@ export default function FinancialCalendar(
const startMonthYear = startYear * 12 + startMonth; const startMonthYear = startYear * 12 + startMonth;
const endMonthYear = yearNum * 12 + baseMonth; const endMonthYear = yearNum * 12 + baseMonth;
let runningBalance = 0; let runningBalance = 0;
const dailyBalances: Record<number, number> = {};
const incomesByDay: Record<number, number[]> = {};
const obligationsByDay: Record<number, number[]> = {};
// console.log("startMonthYear", startMonthYear);
// console.log("endMonthYear", endMonthYear);
let numberNegative = 0;
let numberPositive = 0;
let maxYear = 0;
let minYear = 9999;
if (startMonthYear < endMonthYear) { if (startMonthYear < endMonthYear) {
state.processedIncomes.forEach((inc) => { state.processedIncomes.forEach((inc) => {
if (!inc.received_date) return; if (!inc.received_date) return;
@ -430,9 +468,6 @@ export default function FinancialCalendar(
thisDay >= startDay) thisDay >= startDay)
) { ) {
runningBalance += inc.amount || 0; runningBalance += inc.amount || 0;
numberPositive++;
if (thisYear > maxYear) maxYear = thisYear;
if (thisYear < minYear) minYear = thisYear;
} }
} }
}); });
@ -451,73 +486,34 @@ export default function FinancialCalendar(
thisDay >= startDay) thisDay >= startDay)
) { ) {
runningBalance -= ob.amount || 0; runningBalance -= ob.amount || 0;
numberNegative++;
if (thisYear > maxYear) maxYear = thisYear;
if (thisYear < minYear) minYear = thisYear;
} }
} }
}); });
} }
// console.log("runningBalance", runningBalance);
// console.log("numberPositive", numberPositive);
// console.log("numberNegative", numberNegative);
// console.log("maxYear", maxYear);
// console.log("minYear", minYear);
if (startMonthYear <= endMonthYear) { if (startMonthYear <= endMonthYear) {
runningBalance += state.formBalance || 0; runningBalance += state.formBalance || 0;
const sDay = (startMonthYear < endMonthYear) ? 1 : startDay; const sDay = (startMonthYear < endMonthYear) ? 1 : startDay;
for (let d = 1; d <= daysInThisMonth; d++) {
incomesByDay[d] = [];
obligationsByDay[d] = [];
}
state.processedIncomes.forEach((inc) => {
if (!inc.received_date) return;
const d = MakeNewDate(inc.received_date);
const thisYear = getYear(d);
const thisMonth = getMonth(d);
const thisDay = getDate(d);
const thisMonthYear = thisYear * 12 + thisMonth;
if (thisMonthYear === endMonthYear) {
incomesByDay[thisDay].push(inc.amount || 0);
}
});
state.processedObligations.forEach((ob) => {
if (!ob.due_date) return;
const d = MakeNewDate(ob.due_date);
const thisYear = getYear(d);
const thisMonth = getMonth(d);
const thisDay = getDate(d);
const thisMonthYear = thisYear * 12 + thisMonth;
if (thisMonthYear === endMonthYear) {
obligationsByDay[thisDay].push(ob.amount || 0);
}
});
// Calculate running balance for current month
for (let d = sDay; d <= daysInThisMonth; d++) { for (let d = sDay; d <= daysInThisMonth; d++) {
// Add incomes
if (incomesByDay[d]) { if (incomesByDay[d]) {
for (const amt of incomesByDay[d]) runningBalance += amt; for (const amt of incomesByDay[d]) runningBalance += amt;
} }
// Subtract obligations
if (obligationsByDay[d]) { if (obligationsByDay[d]) {
for (const amt of obligationsByDay[d]) runningBalance -= amt; for (const amt of obligationsByDay[d]) runningBalance -= amt;
} }
dailyBalances[d] = runningBalance; dailyBalances[d] = runningBalance;
} }
} }
}
return { dailyBalances, incomesByDay, obligationsByDay }; return { dailyBalances, incomesByDay, obligationsByDay };
} }
// Render calendar grid // Render calendar grid
const first = MakeNewDate( // Explicitly create date in local time to avoid timezone issues
yearNum + "-" + (monthNum < 10 ? "0" + monthNum : monthNum) + "-" + "01", const first = new Date(yearNum, monthNum - 1, 1);
); // 0=Sun const firstDay = first.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
const firstDay = getDay(first);
let weeks: (string | number)[][] = []; let weeks: (string | number)[][] = [];
let week: (string | number)[] = Array(firstDay).fill(""); let week: (string | number)[] = Array(firstDay).fill("");
for (let day = 1; day <= daysInThisMonth; day++) { for (let day = 1; day <= daysInThisMonth; day++) {

22
main.ts
View File

@ -370,17 +370,17 @@ if (isAppReady) {
// }); // });
// TODO: add a well-known endpoint // TODO: add a well-known endpoint
// app.get("/.well-known/acme-challenge/:token", (ctx: Context<State>) => { app.get("/.well-known/acme-challenge/:token", (ctx: Context<State>) => {
// const token = ctx.params.token; const token = ctx.params.token;
// const content = `${token}.EXAMPLE_ACCOUNT_THUMBPRINT`; const content = `${token}.EXAMPLE_ACCOUNT_THUMBPRINT`;
// return new Response(content, { return new Response(content, {
// status: 200, status: 200,
// headers: { headers: {
// "content-type": "text/plain; charset=utf-8", "content-type": "text/plain; charset=utf-8",
// "cache-control": "public, max-age=0, must-revalidate", "cache-control": "public, max-age=0, must-revalidate",
// }, },
// }); });
// }); });
app.use(InitiPGSessionMiddleware()); app.use(InitiPGSessionMiddleware());
app.use(createAuthMiddleware()); app.use(createAuthMiddleware());

View File

@ -8,6 +8,7 @@ import {
validateRequiredString, validateRequiredString,
withTimeout, withTimeout,
} from "../../../utils/errorHandler.ts"; } from "../../../utils/errorHandler.ts";
import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts";
const REQUEST_TIMEOUT = 30000; // 30 seconds const REQUEST_TIMEOUT = 30000; // 30 seconds
@ -63,6 +64,14 @@ export const handler = define.handlers({
"Request timed out while creating income record", "Request timed out while creating income record",
); );
// Invalidate balance cache for this user
try {
await invalidateBalanceCache(stateData.user_id);
} catch (cacheError) {
console.error("Failed to invalidate cache:", cacheError);
// Non-fatal error - continue with success response
}
return new Response( return new Response(
JSON.stringify({ success: true }), JSON.stringify({ success: true }),
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { "Content-Type": "application/json" } },

View File

@ -2,6 +2,7 @@
import { define } from "../../../utils.ts"; import { define } from "../../../utils.ts";
import { createObligation } from "../../../services/financhalServices.ts"; import { createObligation } from "../../../services/financhalServices.ts";
import { SessionCookie } from "../../auth/callback.ts"; import { SessionCookie } from "../../auth/callback.ts";
import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts";
export const handler = define.handlers({ export const handler = define.handlers({
async POST(ctx) { async POST(ctx) {
@ -23,6 +24,16 @@ export const handler = define.handlers({
encrypted_data ? encrypted_data.toString() : "", encrypted_data ? encrypted_data.toString() : "",
); );
// Invalidate balance cache for this user
if (stateData.user_id) {
try {
await invalidateBalanceCache(stateData.user_id);
} catch (cacheError) {
console.error("Failed to invalidate cache:", cacheError);
// Non-fatal error - continue with success response
}
}
return new Response("", { status: 200 }); return new Response("", { status: 200 });
} catch (e) { } catch (e) {
return new Response(JSON.stringify({ error: e }), { status: 500 }); return new Response(JSON.stringify({ error: e }), { status: 500 });

View File

@ -1,6 +1,8 @@
// routes/api/delete/income.ts // routes/api/delete/income.ts
import { define } from "../../../utils.ts"; import { define } from "../../../utils.ts";
import { deleteIncome } from "../../../services/financhalServices.ts"; import { deleteIncome } from "../../../services/financhalServices.ts";
import { SessionCookie } from "../../auth/callback.ts";
import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts";
export const handler = define.handlers({ export const handler = define.handlers({
async POST(ctx) { async POST(ctx) {
@ -12,8 +14,21 @@ export const handler = define.handlers({
return new Response("Missing income_id or sender", { status: 400 }); return new Response("Missing income_id or sender", { status: 400 });
} }
// Get user_id from session for cache invalidation
const stateData: SessionCookie = ctx.state.data;
await deleteIncome(income_id.toString()); await deleteIncome(income_id.toString());
// Invalidate balance cache for this user
if (stateData.user_id) {
try {
await invalidateBalanceCache(stateData.user_id);
} catch (cacheError) {
console.error("Failed to invalidate cache:", cacheError);
// Non-fatal error - continue with redirect
}
}
// Redirect back to the sender URL (must be absolute) // Redirect back to the sender URL (must be absolute)
const senderPath = sender.toString(); const senderPath = sender.toString();
let redirectUrl = senderPath; let redirectUrl = senderPath;

View File

@ -1,6 +1,8 @@
// routes/api/delete/lease.ts // routes/api/delete/obligation.ts
import { deleteObligation } from "../../../services/financhalServices.ts"; import { deleteObligation } from "../../../services/financhalServices.ts";
import { define } from "../../../utils.ts"; import { define } from "../../../utils.ts";
import { SessionCookie } from "../../auth/callback.ts";
import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts";
export const handler = define.handlers({ export const handler = define.handlers({
async POST(ctx) { async POST(ctx) {
@ -12,9 +14,22 @@ export const handler = define.handlers({
return new Response("Missing obligation_id or sender", { status: 400 }); return new Response("Missing obligation_id or sender", { status: 400 });
} }
// Get user_id from session for cache invalidation
const stateData: SessionCookie = ctx.state.data;
// Delete the obligation using your service function // Delete the obligation using your service function
await deleteObligation(obligation_id.toString()); await deleteObligation(obligation_id.toString());
// Invalidate balance cache for this user
if (stateData.user_id) {
try {
await invalidateBalanceCache(stateData.user_id);
} catch (cacheError) {
console.error("Failed to invalidate cache:", cacheError);
// Non-fatal error - continue with redirect
}
}
// Redirect back to the sender URL (must be absolute) // Redirect back to the sender URL (must be absolute)
const senderPath = sender.toString(); const senderPath = sender.toString();
let redirectUrl = senderPath; let redirectUrl = senderPath;

View File

@ -1,12 +1,17 @@
// routes/api/update/obligation.ts // routes/api/update/income.ts
import { updateIncome } from "../../../services/financhalServices.ts"; import { updateIncome } from "../../../services/financhalServices.ts";
import { define } from "../../../utils.ts"; import { define } from "../../../utils.ts";
import { SessionCookie } from "../../auth/callback.ts";
import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts";
export const handler = define.handlers({ export const handler = define.handlers({
async POST(ctx) { async POST(ctx) {
const contentType = ctx.req.headers.get("content-type") || ""; const contentType = ctx.req.headers.get("content-type") || "";
const isJsonRequest = contentType.includes("application/json"); const isJsonRequest = contentType.includes("application/json");
// Get user_id from session for cache invalidation
const stateData: SessionCookie = ctx.state.data;
let incomeId: string; let incomeId: string;
const updates: Partial<{ const updates: Partial<{
encrypted_data: string; encrypted_data: string;
@ -75,6 +80,16 @@ export const handler = define.handlers({
try { try {
await updateIncome(incomeId, updates); await updateIncome(incomeId, updates);
// Invalidate balance cache for this user
if (stateData.user_id) {
try {
await invalidateBalanceCache(stateData.user_id);
} catch (cacheError) {
console.error("Failed to invalidate cache:", cacheError);
// Non-fatal error - continue with success response
}
}
if (isJsonRequest) { if (isJsonRequest) {
// Return JSON response for JSON requests // Return JSON response for JSON requests
return new Response(JSON.stringify({ success: true }), { return new Response(JSON.stringify({ success: true }), {

View File

@ -1,12 +1,17 @@
// routes/api/update/obligation.ts // routes/api/update/obligation.ts
import { updateObligation } from "../../../services/financhalServices.ts"; import { updateObligation } from "../../../services/financhalServices.ts";
import { define } from "../../../utils.ts"; import { define } from "../../../utils.ts";
import { SessionCookie } from "../../auth/callback.ts";
import { invalidateBalanceCache } from "../../../services/dailyBalanceCache.ts";
export const handler = define.handlers({ export const handler = define.handlers({
async POST(ctx) { async POST(ctx) {
const contentType = ctx.req.headers.get("content-type") || ""; const contentType = ctx.req.headers.get("content-type") || "";
const isJsonRequest = contentType.includes("application/json"); const isJsonRequest = contentType.includes("application/json");
// Get user_id from session for cache invalidation
const stateData: SessionCookie = ctx.state.data;
let obligationId: string; let obligationId: string;
const updates: Partial<{ const updates: Partial<{
encrypted_data: string; encrypted_data: string;
@ -75,6 +80,16 @@ export const handler = define.handlers({
try { try {
await updateObligation(obligationId, updates); await updateObligation(obligationId, updates);
// Invalidate balance cache for this user
if (stateData.user_id) {
try {
await invalidateBalanceCache(stateData.user_id);
} catch (cacheError) {
console.error("Failed to invalidate cache:", cacheError);
// Non-fatal error - continue with success response
}
}
if (isJsonRequest) { if (isJsonRequest) {
// Return JSON response for JSON requests // Return JSON response for JSON requests
return new Response(JSON.stringify({ success: true }), { return new Response(JSON.stringify({ success: true }), {

View File

@ -1,6 +1,4 @@
import FinancialCalendar, { import FinancialCalendar from "../../../../islands/FinancialCalendar.tsx";
getDaysInBaseMonth,
} from "../../../../islands/FinancialCalendar.tsx";
import { define } from "../../../../utils.ts"; import { define } from "../../../../utils.ts";
import { import {
getIncomesByUser, getIncomesByUser,
@ -12,206 +10,18 @@ import {
getFinancialBalancesByUser, getFinancialBalancesByUser,
upsertFinancialBalance, upsertFinancialBalance,
} from "../../../../services/userService.ts"; } from "../../../../services/userService.ts";
import { page } from "fresh";
import { import {
getDate, computeMonthlyBalances,
getMonth, generateRecurringIncomesForMonth,
getYear, generateRecurringObligationsForMonth,
MakeNewDate, invalidateMonthCache,
} from "../../../../helpers.ts"; type MonthlyBalanceCache,
} from "../../../../services/dailyBalanceCache.ts";
import { page } from "fresh";
import { getMonth, getYear } from "../../../../helpers.ts";
import { createPublicId } from "../../../../helpers.ts"; import { createPublicId } from "../../../../helpers.ts";
import { SessionCookie } from "../../../auth/callback.ts"; import { SessionCookie } from "../../../auth/callback.ts";
// Helper function to generate recurring income entries
function generateRecurringIncomes(
incomes: Income[],
targetYear: number,
targetMonth: number,
): Income[] {
const result: Income[] = [];
const baseMonth = targetMonth - 1;
for (const income of incomes) {
if (!income.frequency || !income.received_date) continue;
let currentDate = MakeNewDate(income.received_date);
let daysInMonth = getDaysInBaseMonth(
getYear(currentDate),
getMonth(currentDate),
);
while (
(getYear(currentDate) === targetYear &&
getMonth(currentDate) <= baseMonth) ||
getYear(currentDate) < targetYear
) {
if (getDate(currentDate) <= daysInMonth) {
result.push({
...income,
received_date: currentDate.getTime(),
});
}
switch (income.frequency.toLowerCase()) {
case "daily":
currentDate = MakeNewDate(
currentDate.getTime() + 24 * 60 * 60 * 1000,
);
break;
case "biweekly":
currentDate = MakeNewDate(
currentDate.getTime() + 14 * 24 * 60 * 60 * 1000,
);
break;
case "monthly":
currentDate = MakeNewDate(
currentDate.getTime() +
getDaysInBaseMonth(
getYear(currentDate),
getMonth(currentDate),
) * 24 * 60 * 60 * 1000,
);
break;
case "one-time":
currentDate = MakeNewDate((targetYear + 2) + "-01-01");
break;
case "weekly":
currentDate = MakeNewDate(
currentDate.getTime() + 7 * 24 * 60 * 60 * 1000,
);
break;
case "quarterly":
currentDate = MakeNewDate(
currentDate.getTime() +
Number(
getDaysInBaseMonth(
getYear(currentDate),
getMonth(currentDate),
),
) * 3 * 24 * 60 * 60 * 1000,
);
break;
case "yearly":
case "annually": {
const monthNumber = getMonth(currentDate) + 1;
const monthStr = monthNumber > 9 ? monthNumber : "0" + monthNumber;
currentDate = MakeNewDate(
(getYear(currentDate) + 1) + "-" + monthStr + "-" +
getDate(currentDate),
);
break;
}
default:
// Unknown frequency, skip to prevent infinite loop
currentDate = MakeNewDate((targetYear + 2) + "-01-01");
break;
}
daysInMonth = getDaysInBaseMonth(
getYear(currentDate),
getMonth(currentDate),
);
}
}
return result;
}
// Helper function to generate recurring obligation entries
function generateRecurringObligations(
obligations: Obligation[],
targetYear: number,
targetMonth: number,
): Obligation[] {
const result: Obligation[] = [];
const baseMonth = targetMonth - 1;
for (const obligation of obligations) {
if (!obligation.frequency || !obligation.due_date) continue;
let currentDate = MakeNewDate(obligation.due_date);
let daysInMonth = getDaysInBaseMonth(
getYear(currentDate),
getMonth(currentDate),
);
while (
(getYear(currentDate) === targetYear &&
getMonth(currentDate) <= baseMonth) ||
getYear(currentDate) < targetYear
) {
if (getDate(currentDate) <= daysInMonth) {
result.push({
...obligation,
due_date: currentDate.getTime(),
});
}
switch (obligation.frequency.toLowerCase()) {
case "daily":
currentDate = MakeNewDate(
currentDate.getTime() + 24 * 60 * 60 * 1000,
);
break;
case "biweekly":
currentDate = MakeNewDate(
currentDate.getTime() + 14 * 24 * 60 * 60 * 1000,
);
break;
case "monthly":
currentDate = MakeNewDate(
currentDate.getTime() +
getDaysInBaseMonth(
getYear(currentDate),
getMonth(currentDate),
) * 24 * 60 * 60 * 1000,
);
break;
case "one-time":
currentDate = MakeNewDate((targetYear + 2) + "-01-01");
break;
case "weekly":
currentDate = MakeNewDate(
currentDate.getTime() + 7 * 24 * 60 * 60 * 1000,
);
break;
case "quarterly":
currentDate = MakeNewDate(
currentDate.getTime() +
Number(
getDaysInBaseMonth(
getYear(currentDate),
getMonth(currentDate),
),
) * 3 * 24 * 60 * 60 * 1000,
);
break;
case "yearly":
case "annually": {
const monthNumber = getMonth(currentDate) + 1;
const monthStr = monthNumber > 9 ? monthNumber : "0" + monthNumber;
currentDate = MakeNewDate(
(getYear(currentDate) + 1) + "-" + monthStr + "-" +
getDate(currentDate),
);
break;
}
default:
// Unknown frequency, skip to prevent infinite loop
currentDate = MakeNewDate((targetYear + 2) + "-01-01");
break;
}
daysInMonth = getDaysInBaseMonth(
getYear(currentDate),
getMonth(currentDate),
);
}
}
return result;
}
export const handler = define.handlers({ export const handler = define.handlers({
GET: async (ctx) => { GET: async (ctx) => {
const params = ctx.params; const params = ctx.params;
@ -237,10 +47,12 @@ export const handler = define.handlers({
? await createPublicId(stateData.user_id) ? await createPublicId(stateData.user_id)
: ""; : "";
const isEncrypted = stateData?.user_encrypted; const isEncrypted = stateData?.user_encrypted;
let obligations: Obligation[] = []; let obligations: Obligation[] = [];
let incomes: Income[] = []; let incomes: Income[] = [];
let balance: number | null; let balance: number | null = null;
let date: number | null = null; let date: number | null = null;
let monthlyCache: MonthlyBalanceCache | null = null;
if (stateData?.user_id) { if (stateData?.user_id) {
const rawObligations = await getObligationsByUser(stateData.user_id) ?? const rawObligations = await getObligationsByUser(stateData.user_id) ??
@ -257,20 +69,44 @@ export const handler = define.handlers({
inc.updated_on inc.updated_on
); );
obligations = generateRecurringObligations( // Get base balance
const financialBalances = await getFinancialBalancesByUser(
stateData.user_id,
);
let baseBalance: { date: string; balance: number } | null = null;
if (financialBalances) {
balance = financialBalances.balance ?? null;
date = financialBalances.balance_date ?? null;
if (balance !== null && date !== null) {
baseBalance = {
date: new Date(date).toISOString().slice(0, 10),
balance: balance,
};
}
}
// Compute or retrieve cached monthly balances
monthlyCache = await computeMonthlyBalances(
stateData.user_id,
yearNum,
monthNum,
filteredIncomes,
filteredObligations,
baseBalance,
);
// Generate recurring entries for the current month for display
// (used for day markers and list views - retains encrypted_data)
obligations = generateRecurringObligationsForMonth(
filteredObligations, filteredObligations,
yearNum, yearNum,
monthNum, monthNum,
); );
incomes = generateRecurringIncomes(filteredIncomes, yearNum, monthNum); incomes = generateRecurringIncomesForMonth(
const financialBalances = await getFinancialBalancesByUser( filteredIncomes,
stateData.user_id, yearNum,
monthNum,
); );
if (financialBalances) {
const latestBalance = financialBalances;
balance = latestBalance.balance;
date = latestBalance.balance_date;
}
} }
return page({ return page({
stateData, stateData,
@ -285,6 +121,7 @@ export const handler = define.handlers({
isEncrypted, isEncrypted,
urlRoute: `/calendar/financial-calendar/${yearNum}/${monthNum}`, urlRoute: `/calendar/financial-calendar/${yearNum}/${monthNum}`,
stateId: state.stateId, stateId: state.stateId,
monthlyCache,
}); });
}, },
POST: async (ctx) => { POST: async (ctx) => {
@ -321,6 +158,7 @@ export const handler = define.handlers({
const isEncrypted = stateData?.user_encrypted; const isEncrypted = stateData?.user_encrypted;
let obligations: Obligation[] = []; let obligations: Obligation[] = [];
let incomes: Income[] = []; let incomes: Income[] = [];
let monthlyCache: MonthlyBalanceCache | null = null;
if (stateData?.user_id) { if (stateData?.user_id) {
const rawObligations = await getObligationsByUser(stateData.user_id) ?? const rawObligations = await getObligationsByUser(stateData.user_id) ??
@ -337,14 +175,6 @@ export const handler = define.handlers({
inc.updated_on inc.updated_on
); );
obligations = generateRecurringObligations(
filteredObligations,
yearNum,
monthNum,
);
incomes = generateRecurringIncomes(filteredIncomes, yearNum, monthNum);
console.log("obligations", obligations.length);
console.log("incomes", incomes.length);
if (!balance || !date) { if (!balance || !date) {
const financialBalances = await getFinancialBalancesByUser( const financialBalances = await getFinancialBalancesByUser(
stateData.user_id, stateData.user_id,
@ -361,12 +191,46 @@ export const handler = define.handlers({
} }
} }
} else { } else {
const _updatedBalance = await upsertFinancialBalance( // Update balance and invalidate cache for this month
await upsertFinancialBalance(
stateData.user_id, stateData.user_id,
balance, balance,
date, date,
); );
// Invalidate cache so next request recomputes with new base balance
await invalidateMonthCache(stateData.user_id, yearNum, monthNum);
} }
// Compute or retrieve cached monthly balances
let baseBalance: { date: string; balance: number } | null = null;
if (balance !== null && date !== null) {
baseBalance = {
date: new Date(date).toISOString().slice(0, 10),
balance: balance,
};
}
monthlyCache = await computeMonthlyBalances(
stateData.user_id,
yearNum,
monthNum,
filteredIncomes,
filteredObligations,
baseBalance,
);
// Generate recurring entries for the current month for display
// (used for day markers and list views - retains encrypted_data)
obligations = generateRecurringObligationsForMonth(
filteredObligations,
yearNum,
monthNum,
);
incomes = generateRecurringIncomesForMonth(
filteredIncomes,
yearNum,
monthNum,
);
} }
return page({ return page({
stateData, stateData,
@ -381,6 +245,7 @@ export const handler = define.handlers({
isEncrypted, isEncrypted,
urlRoute: `/calendar/financial-calendar/${yearNum}/${monthNum}`, urlRoute: `/calendar/financial-calendar/${yearNum}/${monthNum}`,
stateId: state.stateId, stateId: state.stateId,
monthlyCache,
}); });
}, },
}); });
@ -400,6 +265,7 @@ export default define.page(
isEncrypted: boolean; isEncrypted: boolean;
urlRoute: string; urlRoute: string;
stateId: string; stateId: string;
monthlyCache?: MonthlyBalanceCache | null;
}; };
}, },
) => { ) => {
@ -414,6 +280,7 @@ export default define.page(
oldBalance={data.balance} oldBalance={data.balance}
oldDate={data.date} oldDate={data.date}
userId={data.publicId} userId={data.publicId}
monthlyCache={data.monthlyCache}
/> />
</div> </div>
</main> </main>

View File

@ -17,7 +17,7 @@ import {
import { db } from "../../../../db/db.ts"; import { db } from "../../../../db/db.ts";
import { leases, payment_transactions } from "../../../../db/schema.ts"; import { leases, payment_transactions } from "../../../../db/schema.ts";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Property, Lease, User } from "../../../../db/interfaces.ts"; import type { Lease, Property, User } from "../../../../db/interfaces.ts";
interface PropertyDetailsData { interface PropertyDetailsData {
property: Property | null; property: Property | null;
@ -106,9 +106,7 @@ export const handler = define.handlers({
} }
// Sort payments by date (newest first) // Sort payments by date (newest first)
payments.sort((a, b) => payments.sort((a, b) => (b.payment_date || 0) - (a.payment_date || 0));
(b.payment_date || 0) - (a.payment_date || 0)
);
// Fetch all expenses for this property // Fetch all expenses for this property
const expenses = await getExpensesByProperty(id); const expenses = await getExpensesByProperty(id);
@ -137,7 +135,8 @@ export const handler = define.handlers({
}, },
}); });
export default define.page(function PropertyDetailsPage({ data }: { data: PropertyDetailsData }) { export default define.page(
function PropertyDetailsPage({ data }: { data: PropertyDetailsData }) {
const { property, leases, payments, expenses, rentHistory, error } = data; const { property, leases, payments, expenses, rentHistory, error } = data;
if (error) { if (error) {
@ -162,7 +161,9 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
<div class="dashboard-error-page"> <div class="dashboard-error-page">
<div class="dashboard-error-inner"> <div class="dashboard-error-inner">
<h1 class="dashboard-error-title">Property Not Found</h1> <h1 class="dashboard-error-title">Property Not Found</h1>
<p class="dashboard-error-text">The property you're looking for doesn't exist.</p> <p class="dashboard-error-text">
The property you're looking for doesn't exist.
</p>
<a href="/landlord/properties" class="dashboard-error-link"> <a href="/landlord/properties" class="dashboard-error-link">
Back to Properties Back to Properties
</a> </a>
@ -181,7 +182,8 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
const expensesByCategory: Record<string, number> = {}; const expensesByCategory: Record<string, number> = {};
for (const expense of expenses) { for (const expense of expenses) {
const category = expense.category || "uncategorized"; const category = expense.category || "uncategorized";
expensesByCategory[category] = (expensesByCategory[category] || 0) + (expense.amount || 0); expensesByCategory[category] = (expensesByCategory[category] || 0) +
(expense.amount || 0);
} }
return ( return (
@ -237,13 +239,15 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
<div> <div>
<dt class="dashboard-stat-label">Status</dt> <dt class="dashboard-stat-label">Status</dt>
<dd> <dd>
<span class={`dashboard-badge capitalize ${ <span
class={`dashboard-badge capitalize ${
property.rental_status === "occupied" property.rental_status === "occupied"
? "dashboard-badge--green" ? "dashboard-badge--green"
: property.rental_status === "available" : property.rental_status === "available"
? "dashboard-badge--blue" ? "dashboard-badge--blue"
: "dashboard-badge--yellow" : "dashboard-badge--yellow"
}`}> }`}
>
{property.rental_status || "unknown"} {property.rental_status || "unknown"}
</span> </span>
</dd> </dd>
@ -292,7 +296,9 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
</div> </div>
<div class="dashboard-stat-content"> <div class="dashboard-stat-content">
<dl> <dl>
<dt class="dashboard-stat-label">Total Payments Received</dt> <dt class="dashboard-stat-label">
Total Payments Received
</dt>
<dd class="dashboard-stat-value"> <dd class="dashboard-stat-value">
${totalPayments.toLocaleString()} ${totalPayments.toLocaleString()}
</dd> </dd>
@ -353,9 +359,11 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
<div class="dashboard-stat-content"> <div class="dashboard-stat-content">
<dl> <dl>
<dt class="dashboard-stat-label">Net Income</dt> <dt class="dashboard-stat-label">Net Income</dt>
<dd class={`dashboard-stat-value ${ <dd
class={`dashboard-stat-value ${
netIncome >= 0 ? "text-success" : "text-danger" netIncome >= 0 ? "text-success" : "text-danger"
}`}> }`}
>
${netIncome.toLocaleString()} ${netIncome.toLocaleString()}
</dd> </dd>
</dl> </dl>
@ -386,7 +394,8 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
<dl> <dl>
<dt class="dashboard-stat-label">Active Leases</dt> <dt class="dashboard-stat-label">Active Leases</dt>
<dd class="dashboard-stat-value"> <dd class="dashboard-stat-value">
{leases.filter((l) => l.lease_status === "active").length} {leases.filter((l) => l.lease_status === "active")
.length}
</dd> </dd>
</dl> </dl>
</div> </div>
@ -570,15 +579,14 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
<div key={lease.id} class="dashboard-row"> <div key={lease.id} class="dashboard-row">
<div> <div>
<p class="dashboard-row-label"> <p class="dashboard-row-label">
{lease.title || `Lease #${lease.id.slice(0, 8)}`} {lease.title ||
`Lease #${lease.id.slice(0, 8)}`}
</p> </p>
<p class="dashboard-row-muted"> <p class="dashboard-row-muted">
{lease.start_date {lease.start_date
? new Date(lease.start_date) ? new Date(lease.start_date)
.toLocaleDateString() .toLocaleDateString()
: ""}{" "} : ""} - {lease.end_date
-{" "}
{lease.end_date
? new Date(lease.end_date) ? new Date(lease.end_date)
.toLocaleDateString() .toLocaleDateString()
: ""} : ""}
@ -640,7 +648,10 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
.toLocaleDateString() .toLocaleDateString()
: "N/A"} : "N/A"}
{entry.is_current && ( {entry.is_current && (
<span class="dashboard-badge dashboard-badge--green" style={{ marginLeft: "8px" }}> <span
class="dashboard-badge dashboard-badge--green"
style={{ marginLeft: "8px" }}
>
Current Current
</span> </span>
)} )}
@ -654,19 +665,17 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
<td class="dashboard-table-td"> <td class="dashboard-table-td">
{previousRent > 0 && ( {previousRent > 0 && (
<span <span
class={ class={isIncrease
isIncrease
? "text-success" ? "text-success"
: isDecrease : isDecrease
? "text-danger" ? "text-danger"
: "" : ""}
}
> >
{isIncrease && "+"} {isIncrease && "+"}
${changeAmount.toLocaleString()} ${changeAmount.toLocaleString()}
{changeAmount !== 0 && ` (${ {changeAmount !== 0 && ` (${
Math.round( Math.round(
(changeAmount / previousRent) * 100 (changeAmount / previousRent) * 100,
) )
}%)`} }%)`}
</span> </span>
@ -696,4 +705,5 @@ export default define.page(function PropertyDetailsPage({ data }: { data: Proper
</div> </div>
</div> </div>
); );
}); },
);

View File

@ -399,7 +399,8 @@ export default define.page(function TenantDashboard({ data }) {
<dt class="dashboard-stat-label">Address</dt> <dt class="dashboard-stat-label">Address</dt>
<dd class="dashboard-stat-value"> <dd class="dashboard-stat-value">
{currentProperty {currentProperty
? buildPropertyAddress(currentProperty) || "Property Address" ? buildPropertyAddress(currentProperty) ||
"Property Address"
: currentLease.property_address || "Property Address"} : currentLease.property_address || "Property Address"}
</dd> </dd>
</div> </div>

View File

@ -0,0 +1,423 @@
// services/dailyBalanceCache.ts
// Cached daily balance computation with incremental updates and time-based expiration
import { kvStore } from "../modules/KvCacheStore.ts";
import { Income, Obligation } from "./financhalServices.ts";
export interface DailyBalanceEntry {
date: string; // YYYY-MM-DD
balance: number;
dayIncome: number;
dayObligation: number;
}
export interface MonthlyBalanceCache {
userId: string;
year: number;
month: number; // 1-12
startDate: string; // YYYY-MM-DD - first day with balance data
startBalance: number; // Balance at startDate (before day's transactions)
entries: DailyBalanceEntry[];
computedAt: number; // Unix timestamp
}
export interface ComputeBalanceOptions {
ttl?: number; // Cache TTL in milliseconds (default: 1 hour)
forceRecompute?: boolean;
}
const DEFAULT_CACHE_TTL = 60 * 60 * 1000; // 1 hour
const CACHE_PREFIX = ["daily_balances"];
// Get cache key for a user's month
function getCacheKey(userId: string, year: number, month: number): Deno.KvKey {
return [...CACHE_PREFIX, userId, String(year), String(month)];
}
// Get days in month
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
// Format date to YYYY-MM-DD
function formatDateStr(year: number, month: number, day: number): string {
const monthStr = month < 9 ? `0${month + 1}` : `${month + 1}`;
const dayStr = day < 10 ? `0${day}` : `${day}`;
return `${year}-${monthStr}-${dayStr}`;
}
// Advance a date by frequency
function advanceDate(
date: Date,
frequency: string,
): Date {
switch (frequency.toLowerCase()) {
case "daily":
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);
case "weekly":
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 7);
case "biweekly":
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + 14);
case "monthly": {
// Advance by one month, preserving the day number
return new Date(date.getFullYear(), date.getMonth() + 1, date.getDate());
}
case "quarterly":
return new Date(date.getFullYear(), date.getMonth() + 3, date.getDate());
case "yearly":
case "annually": {
// Advance by year, preserving month/day
const newYear = date.getFullYear() + 1;
const newMonth = date.getMonth();
const newDay = date.getDate();
return new Date(newYear, newMonth, newDay);
}
default:
return date; // Unknown frequency - don't advance
}
}
// Generate recurring income occurrences for a specific month (for display)
export function generateRecurringIncomesForMonth(
incomes: Income[],
targetYear: number,
targetMonth: number,
): Income[] {
const result: Income[] = [];
for (const income of incomes) {
if (!income.frequency || !income.received_date) continue;
let currentDate = new Date(income.received_date);
const targetMonthIdx = targetMonth - 1;
// Advance until we reach or pass the target month/year
while (
currentDate.getFullYear() < targetYear ||
currentDate.getMonth() < targetMonthIdx
) {
currentDate = advanceDate(currentDate, income.frequency);
// Safety check for one-time or unknown frequencies
if (
income.frequency.toLowerCase() === "one-time" ||
currentDate.getFullYear() > targetYear + 2
) {
break;
}
}
// Add all occurrences in target month
while (
currentDate.getFullYear() === targetYear &&
currentDate.getMonth() === targetMonthIdx
) {
// Create timestamp using local date components to avoid timezone issues
const localTimestamp = new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
currentDate.getDate(),
).getTime();
result.push({
...income,
received_date: localTimestamp,
});
currentDate = advanceDate(currentDate, income.frequency);
}
}
return result;
}
// Generate recurring obligation occurrences for a specific month (for display)
export function generateRecurringObligationsForMonth(
obligations: Obligation[],
targetYear: number,
targetMonth: number,
): Obligation[] {
const result: Obligation[] = [];
for (const obligation of obligations) {
if (!obligation.frequency || !obligation.due_date) continue;
let currentDate = new Date(obligation.due_date);
const targetMonthIdx = targetMonth - 1;
// Advance until we reach or pass the target month/year
while (
currentDate.getFullYear() < targetYear ||
currentDate.getMonth() < targetMonthIdx
) {
currentDate = advanceDate(
currentDate,
obligation.frequency,
);
// Safety check for one-time or unknown frequencies
if (
obligation.frequency.toLowerCase() === "one-time" ||
currentDate.getFullYear() > targetYear + 2
) {
break;
}
}
// Add all occurrences in target month
while (
currentDate.getFullYear() === targetYear &&
currentDate.getMonth() === targetMonthIdx
) {
// Create timestamp using local date components to avoid timezone issues
const localTimestamp = new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
currentDate.getDate(),
).getTime();
result.push({
...obligation,
due_date: localTimestamp,
});
currentDate = advanceDate(
currentDate,
obligation.frequency,
);
}
}
return result;
}
// Parse YYYY-MM-DD string to date components
function parseDateStr(
dateStr: string,
): { year: number; month: number; day: number } {
const [year, month, day] = dateStr.split("-").map(Number);
return { year, month: month - 1, day }; // month is 0-indexed in Date
}
// Compute daily balances for a specific month
export async function computeMonthlyBalances(
userId: string,
year: number,
month: number,
incomes: Income[],
obligations: Obligation[],
baseBalance: { date: string; balance: number } | null,
options: ComputeBalanceOptions = {},
): Promise<MonthlyBalanceCache> {
const { ttl = DEFAULT_CACHE_TTL, forceRecompute = false } = options;
// Check cache first (unless force recompute)
if (!forceRecompute) {
const cached = await getCachedMonthlyBalances(userId, year, month);
if (cached && (Date.now() - cached.computedAt) < ttl) {
return cached;
}
}
// Compute from scratch or incremental
const result: MonthlyBalanceCache = {
userId,
year,
month,
startDate: baseBalance?.date || formatDateStr(year, month - 1, 1),
startBalance: baseBalance?.balance || 0,
entries: [],
computedAt: Date.now(),
};
const daysInMonth = getDaysInMonth(year, month - 1);
let runningBalance = baseBalance?.balance || 0;
// If we have a base balance, start from that date
const startDate = baseBalance ? parseDateStr(baseBalance.date) : null;
// Compute incomes by day for this month
const incomesByDay: Record<number, number[]> = {};
const obligationsByDay: Record<number, number[]> = {};
// Initialize day arrays
for (let d = 1; d <= daysInMonth; d++) {
incomesByDay[d] = [];
obligationsByDay[d] = [];
}
// Generate recurring incomes for this month
for (const income of incomes) {
if (!income.frequency || !income.received_date) continue;
let currentDate = new Date(income.received_date);
const targetMonth = month - 1; // 0-indexed
// Advance until we reach or pass the target month/year
while (
currentDate.getFullYear() < year || currentDate.getMonth() < targetMonth
) {
currentDate = advanceDate(
currentDate,
income.frequency,
);
// Safety check for one-time or unknown frequencies
if (
income.frequency.toLowerCase() === "one-time" ||
currentDate.getFullYear() > year + 2
) {
break;
}
}
// Add all occurrences in target month
while (
currentDate.getFullYear() === year &&
currentDate.getMonth() === targetMonth
) {
const day = currentDate.getDate();
if (day <= daysInMonth) {
incomesByDay[day].push(income.amount || 0);
}
currentDate = advanceDate(
currentDate,
income.frequency,
);
}
}
// Generate recurring obligations for this month
for (const obligation of obligations) {
if (!obligation.frequency || !obligation.due_date) continue;
let currentDate = new Date(obligation.due_date);
const targetMonth = month - 1; // 0-indexed
// Advance until we reach or pass the target month/year
while (
currentDate.getFullYear() < year || currentDate.getMonth() < targetMonth
) {
currentDate = advanceDate(
currentDate,
obligation.frequency,
);
// Safety check for one-time or unknown frequencies
if (
obligation.frequency.toLowerCase() === "one-time" ||
currentDate.getFullYear() > year + 2
) {
break;
}
}
// Add all occurrences in target month
while (
currentDate.getFullYear() === year &&
currentDate.getMonth() === targetMonth
) {
const day = currentDate.getDate();
if (day <= daysInMonth) {
obligationsByDay[day].push(obligation.amount || 0);
}
currentDate = advanceDate(
currentDate,
obligation.frequency,
);
}
}
// Calculate running balance for each day
const startDay = startDate &&
startDate.year === year &&
startDate.month === month - 1
? startDate.day
: 1;
for (let day = startDay; day <= daysInMonth; day++) {
const dayIncome = incomesByDay[day].reduce((sum, amt) => sum + amt, 0);
const dayObligation = obligationsByDay[day].reduce(
(sum, amt) => sum + amt,
0,
);
runningBalance += dayIncome - dayObligation;
result.entries.push({
date: formatDateStr(year, month - 1, day),
balance: runningBalance,
dayIncome,
dayObligation,
});
}
// Cache the result
await cacheMonthlyBalances(result);
return result;
}
// Cache monthly balances
export async function cacheMonthlyBalances(
cache: MonthlyBalanceCache,
): Promise<void> {
const key = getCacheKey(cache.userId, cache.year, cache.month);
await kvStore.set(key, cache);
}
// Retrieve cached monthly balances
export async function getCachedMonthlyBalances(
userId: string,
year: number,
month: number,
): Promise<MonthlyBalanceCache | null> {
const key = getCacheKey(userId, year, month);
return await kvStore.get<MonthlyBalanceCache>(key);
}
// Invalidate cache for a user (call when incomes/obligations change)
export async function invalidateBalanceCache(userId: string): Promise<void> {
// List all cache entries for this user
const entries: Array<{ key: Deno.KvKey; value: unknown }> = [];
for await (
const entry of kvStore.list<MonthlyBalanceCache>({
prefix: [...CACHE_PREFIX, userId],
})
) {
entries.push(entry);
}
// Delete all entries
for (const entry of entries) {
await kvStore.delete(entry.key);
}
}
// Invalidate cache for a specific month (more targeted)
export async function invalidateMonthCache(
userId: string,
year: number,
month: number,
): Promise<void> {
const key = getCacheKey(userId, year, month);
await kvStore.delete(key);
}
// Clear all expired cache entries (cleanup utility)
export async function clearExpiredCache(
maxAge: number = DEFAULT_CACHE_TTL,
): Promise<number> {
const now = Date.now();
let deletedCount = 0;
for await (
const entry of kvStore.list<MonthlyBalanceCache>({
prefix: CACHE_PREFIX,
})
) {
const cache = entry.value;
if (now - cache.computedAt > maxAge) {
await kvStore.delete(entry.key);
deletedCount++;
}
}
return deletedCount;
}

View File

@ -1,7 +1,7 @@
// services/expenseService.ts // services/expenseService.ts
// Handle property expense management and reporting // Handle property expense management and reporting
import { and, eq, between, desc } from "drizzle-orm"; import { and, between, desc, eq } from "drizzle-orm";
import { db } from "../db/db.ts"; import { db } from "../db/db.ts";
import { property_expenses } from "../db/schema.ts"; import { property_expenses } from "../db/schema.ts";
@ -54,7 +54,9 @@ export async function createExpense(
/** /**
* Get expense by ID * Get expense by ID
*/ */
export async function getExpenseById(expenseId: string): Promise<PropertyExpense | null> { export async function getExpenseById(
expenseId: string,
): Promise<PropertyExpense | null> {
const result = await db.select() const result = await db.select()
.from(property_expenses) .from(property_expenses)
.where(eq(property_expenses.id, expenseId)); .where(eq(property_expenses.id, expenseId));
@ -64,7 +66,9 @@ export async function getExpenseById(expenseId: string): Promise<PropertyExpense
/** /**
* Get all expenses for a property * Get all expenses for a property
*/ */
export async function getExpensesByProperty(property_id: string): Promise<PropertyExpense[]> { export async function getExpensesByProperty(
property_id: string,
): Promise<PropertyExpense[]> {
return await db.select() return await db.select()
.from(property_expenses) .from(property_expenses)
.where(eq(property_expenses.property_id, property_id)) .where(eq(property_expenses.property_id, property_id))
@ -111,7 +115,9 @@ export async function getExpensesByPropertyAndCategory(
/** /**
* Get recurring expenses for a property * Get recurring expenses for a property
*/ */
export async function getRecurringExpenses(property_id: string): Promise<PropertyExpense[]> { export async function getRecurringExpenses(
property_id: string,
): Promise<PropertyExpense[]> {
return await db.select() return await db.select()
.from(property_expenses) .from(property_expenses)
.where( .where(
@ -169,7 +175,9 @@ export async function getExpenseSummary(
if (exp.expense_date) { if (exp.expense_date) {
const date = new Date(exp.expense_date); const date = new Date(exp.expense_date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; const monthKey = `${date.getFullYear()}-${
String(date.getMonth() + 1).padStart(2, "0")
}`;
byMonth[monthKey] = (byMonth[monthKey] || 0) + (exp.amount || 0); byMonth[monthKey] = (byMonth[monthKey] || 0) + (exp.amount || 0);
} }
} }

View File

@ -1,9 +1,9 @@
// services/rentChangeService.ts // services/rentChangeService.ts
// Handle rent change history tracking for properties // Handle rent change history tracking for properties
import { eq, desc } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import { db } from "../db/db.ts"; import { db } from "../db/db.ts";
import { rent_changes, properties } from "../db/schema.ts"; import { properties, rent_changes } from "../db/schema.ts";
// Use the type from the schema // Use the type from the schema
export type RentChange = typeof rent_changes.$inferSelect; export type RentChange = typeof rent_changes.$inferSelect;
@ -58,7 +58,9 @@ export async function createRentChange(
/** /**
* Get all rent changes for a property, ordered by effective date (newest first) * Get all rent changes for a property, ordered by effective date (newest first)
*/ */
export async function getRentChangesByProperty(property_id: string): Promise<RentChange[]> { export async function getRentChangesByProperty(
property_id: string,
): Promise<RentChange[]> {
return await db.select() return await db.select()
.from(rent_changes) .from(rent_changes)
.where(eq(rent_changes.property_id, property_id)) .where(eq(rent_changes.property_id, property_id))
@ -68,7 +70,9 @@ export async function getRentChangesByProperty(property_id: string): Promise<Ren
/** /**
* Get the current rent for a property (most recent effective date that has passed) * Get the current rent for a property (most recent effective date that has passed)
*/ */
export async function getCurrentRentForProperty(property_id: string): Promise<number | null> { export async function getCurrentRentForProperty(
property_id: string,
): Promise<number | null> {
const now = Date.now(); const now = Date.now();
const result = await db.select() const result = await db.select()
.from(rent_changes) .from(rent_changes)
@ -95,7 +99,9 @@ export async function getCurrentRentForProperty(property_id: string): Promise<nu
/** /**
* Get rent change by ID * Get rent change by ID
*/ */
export async function getRentChangeById(changeId: string): Promise<RentChange | null> { export async function getRentChangeById(
changeId: string,
): Promise<RentChange | null> {
const result = await db.select() const result = await db.select()
.from(rent_changes) .from(rent_changes)
.where(eq(rent_changes.id, changeId)); .where(eq(rent_changes.id, changeId));
@ -105,7 +111,9 @@ export async function getRentChangeById(changeId: string): Promise<RentChange |
/** /**
* Get upcoming rent changes for a property (effective_date is in the future) * Get upcoming rent changes for a property (effective_date is in the future)
*/ */
export async function getUpcomingRentChanges(property_id: string): Promise<RentChange[]> { export async function getUpcomingRentChanges(
property_id: string,
): Promise<RentChange[]> {
const now = Date.now(); const now = Date.now();
return await db.select() return await db.select()
.from(rent_changes) .from(rent_changes)
@ -116,13 +124,15 @@ export async function getUpcomingRentChanges(property_id: string): Promise<RentC
/** /**
* Get rent history for a property including current rent from the property record * Get rent history for a property including current rent from the property record
*/ */
export async function getRentHistory(property_id: string): Promise<Array<{ export async function getRentHistory(property_id: string): Promise<
Array<{
effective_date: number; effective_date: number;
monthly_rent: number; monthly_rent: number;
previous_rent: number | null; previous_rent: number | null;
reason: string | null; reason: string | null;
is_current: boolean; is_current: boolean;
}>> { }>
> {
// Get the property's current rent // Get the property's current rent
const propertyResult = await db.select({ const propertyResult = await db.select({
monthly_rent: properties.monthly_rent, monthly_rent: properties.monthly_rent,

View File

@ -64,7 +64,7 @@ export function InitiPGSessionMiddleware() {
return define.middleware(async (ctx: Context<State>) => { return define.middleware(async (ctx: Context<State>) => {
const url = new URL(ctx.req.url); const url = new URL(ctx.req.url);
console.log("Pathname:", ctx.req.method, url.pathname); //console.log("Pathname:", ctx.req.method, url.pathname);
if ( if (
url.pathname.startsWith("/api/ai-images/file/") || url.pathname.startsWith("/api/ai-images/file/") ||

View File

@ -104,11 +104,9 @@ function toPlainEmail(email: string): string {
} }
export async function getTenantInvitesByEmail(email: string) { export async function getTenantInvitesByEmail(email: string) {
const normalizedEmail = toPlainEmail(email); const normalizedEmail = toPlainEmail(email);
if (!normalizedEmail) { if (!normalizedEmail) {
return []; return [];
} }
// ILIKE substring match then exact filter (handles JSON array and comma-separated stored format). // ILIKE substring match then exact filter (handles JSON array and comma-separated stored format).
@ -117,7 +115,6 @@ export async function getTenantInvitesByEmail(email: string) {
ilike(tenant_invites.invited_emails, pattern), ilike(tenant_invites.invited_emails, pattern),
); );
return result.filter((invite) => { return result.filter((invite) => {
return hasMatchingInviteEmail(invite, normalizedEmail); return hasMatchingInviteEmail(invite, normalizedEmail);
}); });
} }