integrated hub
This commit is contained in:
parent
bfa90b7377
commit
f5fa820530
@ -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
|
||||||
|
|||||||
@ -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
22
main.ts
@ -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());
|
||||||
|
|||||||
@ -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" } },
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 }), {
|
||||||
|
|||||||
@ -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 }), {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
423
services/dailyBalanceCache.ts
Normal file
423
services/dailyBalanceCache.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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/") ||
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user