Production deployment setup + feature complete
- Dockerfile + deploy.sh for Hetzner server - Email verification via Better Auth + Resend - Invite code flow (6-digit OTP, generate/join) - Settlement share percent fix (payer vs debtor) - OCR scanner fixes (date display, retry, viewfinder) - app.json icon/splash/adaptive-icon configured - iOS deployment target 15.5 (ML Kit requirement) - DB migration 0014: household_invitations table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
17
apps/server/.env.example
Normal file
17
apps/server/.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/haushaltsapp
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
CORS_ORIGIN=http://localhost:3001
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
NODE_ENV=development
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_TEAM_ID=
|
||||
APPLE_KEY_ID=
|
||||
APPLE_PRIVATE_KEY=
|
||||
MOBILE_APP_SCHEME=haushaltsapp://
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@haushaltsapp.local
|
||||
2
apps/server/bunfig.toml
Normal file
2
apps/server/bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[test]
|
||||
preload = ["./src/__tests__/setup.ts"]
|
||||
@@ -7,14 +7,17 @@
|
||||
"check-types": "tsc -b",
|
||||
"compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server",
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"start": "bun run dist/index.mjs"
|
||||
"start": "bun run dist/index.mjs",
|
||||
"test": "bun test ./src/__tests__"
|
||||
},
|
||||
"dependencies": {
|
||||
"@haushaltsApp/auth": "workspace:*",
|
||||
"@haushaltsApp/db": "workspace:*",
|
||||
"@haushaltsApp/env": "workspace:*",
|
||||
"@haushaltsApp/shared": "workspace:*",
|
||||
"better-auth": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"hono": "^4.8.2",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
65
apps/server/src/__tests__/helpers/test-context.ts
Normal file
65
apps/server/src/__tests__/helpers/test-context.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { auth } from "@haushaltsApp/auth";
|
||||
import app from "../../index";
|
||||
|
||||
export interface TestContext {
|
||||
token: string;
|
||||
householdId: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function createTestContext(suffix?: string): Promise<TestContext> {
|
||||
const unique = suffix ?? String(Date.now());
|
||||
const email = `test-${unique}@example.com`;
|
||||
const password = "TestPassword123!";
|
||||
const name = `Test User ${unique}`;
|
||||
|
||||
// 1. Register user
|
||||
const signUpRes = await app.request("/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
});
|
||||
if (!signUpRes.ok) throw new Error(`Sign-up failed: ${await signUpRes.text()}`);
|
||||
|
||||
// 2. Sign in to get token
|
||||
const signInRes = await app.request("/api/auth/sign-in/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!signInRes.ok) throw new Error(`Sign-in failed: ${await signInRes.text()}`);
|
||||
const signInBody = (await signInRes.json()) as { token: string; user: { id: string } };
|
||||
const token = signInBody.token;
|
||||
const userId = signInBody.user.id;
|
||||
|
||||
// 3. Create organization (household) via Better Auth API
|
||||
const orgRes = await auth.api.createOrganization({
|
||||
body: {
|
||||
name: `Household ${unique}`,
|
||||
slug: `household-${unique}`,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
const householdId = orgRes.id;
|
||||
|
||||
// 4. Seed default categories
|
||||
await app.request("/api/households/setup", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"x-household-id": householdId,
|
||||
},
|
||||
});
|
||||
|
||||
return { token, householdId, userId, email };
|
||||
}
|
||||
|
||||
export function authHeaders(token: string, householdId: string) {
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"x-household-id": householdId,
|
||||
};
|
||||
}
|
||||
54
apps/server/src/__tests__/routes/auth.test.ts
Normal file
54
apps/server/src/__tests__/routes/auth.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { afterAll, describe, expect, it } from "bun:test";
|
||||
import { db, eq } from "@haushaltsApp/db";
|
||||
import { account, session, user } from "@haushaltsApp/db/schema";
|
||||
import app from "../../index";
|
||||
|
||||
const TEST_EMAIL = `test-${Date.now()}@example.com`;
|
||||
const TEST_PASSWORD = "TestPassword123!";
|
||||
const TEST_NAME = "Test User";
|
||||
|
||||
describe("Auth Routes", () => {
|
||||
afterAll(async () => {
|
||||
// Clean up test user and related records
|
||||
const [testUser] = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.email, TEST_EMAIL));
|
||||
if (testUser) {
|
||||
await db.delete(session).where(eq(session.userId, testUser.id));
|
||||
await db.delete(account).where(eq(account.userId, testUser.id));
|
||||
await db.delete(user).where(eq(user.id, testUser.id));
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /api/auth/sign-up/email — creates user", async () => {
|
||||
const res = await app.request("/api/auth/sign-up/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
name: TEST_NAME,
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { user: { email: string } };
|
||||
expect(body.user.email).toBe(TEST_EMAIL);
|
||||
});
|
||||
|
||||
it("POST /api/auth/sign-in/email — returns session", async () => {
|
||||
const res = await app.request("/api/auth/sign-in/email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { token?: string; user?: { email: string } };
|
||||
// Better Auth sign-in returns { token, user } (session stored server-side via cookie)
|
||||
expect(body.token).toBeDefined();
|
||||
expect(body.user?.email).toBe(TEST_EMAIL);
|
||||
});
|
||||
});
|
||||
13
apps/server/src/__tests__/routes/health.test.ts
Normal file
13
apps/server/src/__tests__/routes/health.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import app from "../../index";
|
||||
|
||||
describe("GET /health", () => {
|
||||
it("returns 200 with status ok", async () => {
|
||||
const res = await app.request("/health");
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json() as { status: string; timestamp: string };
|
||||
expect(body.status).toBe("ok");
|
||||
expect(typeof body.timestamp).toBe("string");
|
||||
});
|
||||
});
|
||||
35
apps/server/src/__tests__/routes/households.test.ts
Normal file
35
apps/server/src/__tests__/routes/households.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { db, eq } from "@haushaltsApp/db";
|
||||
import { account, categories, households as householdsTable, session, user } from "@haushaltsApp/db/schema";
|
||||
import app from "../../index";
|
||||
import { authHeaders, createTestContext, type TestContext } from "../helpers/test-context";
|
||||
|
||||
let ctx: TestContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext(`hh-${Date.now()}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const [u] = await db.select().from(user).where(eq(user.email, ctx.email));
|
||||
if (u) {
|
||||
await db.delete(categories).where(eq(categories.householdId, ctx.householdId));
|
||||
await db.delete(householdsTable).where(eq(householdsTable.id, ctx.householdId));
|
||||
await db.delete(session).where(eq(session.userId, u.id));
|
||||
await db.delete(account).where(eq(account.userId, u.id));
|
||||
await db.delete(user).where(eq(user.id, u.id));
|
||||
}
|
||||
});
|
||||
|
||||
describe("Household Routes", () => {
|
||||
it("GET /api/households — returns list of households for user", async () => {
|
||||
const res = await app.request("/api/households", {
|
||||
headers: authHeaders(ctx.token, ctx.householdId),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { households: { id: string; name: string; role: string }[] };
|
||||
expect(Array.isArray(body.households)).toBe(true);
|
||||
expect(body.households.length).toBeGreaterThan(0);
|
||||
expect(body.households[0]?.id).toBe(ctx.householdId);
|
||||
});
|
||||
});
|
||||
100
apps/server/src/__tests__/routes/transactions.test.ts
Normal file
100
apps/server/src/__tests__/routes/transactions.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { db, eq } from "@haushaltsApp/db";
|
||||
import { account, categories, session, transactions, user } from "@haushaltsApp/db/schema";
|
||||
import app from "../../index";
|
||||
import { authHeaders, createTestContext, type TestContext } from "../helpers/test-context";
|
||||
|
||||
let ctx: TestContext;
|
||||
let ctx2: TestContext; // Second household for tenant-isolation test
|
||||
let createdTransactionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const ts = Date.now();
|
||||
ctx = await createTestContext(`tx-a-${ts}`);
|
||||
ctx2 = await createTestContext(`tx-b-${ts}`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up both test contexts
|
||||
for (const email of [ctx.email, ctx2.email]) {
|
||||
const [u] = await db.select().from(user).where(eq(user.email, email));
|
||||
if (u) {
|
||||
await db.delete(transactions).where(eq(transactions.userId, u.id));
|
||||
await db.delete(categories).where(eq(categories.householdId, ctx.householdId));
|
||||
await db.delete(session).where(eq(session.userId, u.id));
|
||||
await db.delete(account).where(eq(account.userId, u.id));
|
||||
await db.delete(user).where(eq(user.id, u.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("Transaction Routes", () => {
|
||||
it("POST /api/transactions — creates transaction", async () => {
|
||||
const res = await app.request("/api/transactions", {
|
||||
method: "POST",
|
||||
headers: authHeaders(ctx.token, ctx.householdId),
|
||||
body: JSON.stringify({
|
||||
amount: 42.5,
|
||||
type: "expense",
|
||||
description: "Supermarkt",
|
||||
date: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { transaction: { id: string; amount: string } };
|
||||
expect(body.transaction.id).toBeDefined();
|
||||
expect(Number(body.transaction.amount)).toBeCloseTo(42.5);
|
||||
createdTransactionId = body.transaction.id;
|
||||
});
|
||||
|
||||
it("GET /api/transactions — returns list", async () => {
|
||||
const res = await app.request("/api/transactions", {
|
||||
headers: authHeaders(ctx.token, ctx.householdId),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { transactions: unknown[] };
|
||||
expect(Array.isArray(body.transactions)).toBe(true);
|
||||
expect(body.transactions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("GET /api/transactions/summary — returns income/expense/balance", async () => {
|
||||
const res = await app.request("/api/transactions/summary", {
|
||||
headers: authHeaders(ctx.token, ctx.householdId),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { income: number; expense: number; balance: number };
|
||||
expect(typeof body.income).toBe("number");
|
||||
expect(typeof body.expense).toBe("number");
|
||||
expect(typeof body.balance).toBe("number");
|
||||
expect(body.expense).toBeCloseTo(42.5);
|
||||
});
|
||||
|
||||
it("PATCH /api/transactions/:id — updates description", async () => {
|
||||
const res = await app.request(`/api/transactions/${createdTransactionId}`, {
|
||||
method: "PATCH",
|
||||
headers: authHeaders(ctx.token, ctx.householdId),
|
||||
body: JSON.stringify({ description: "Updated description" }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { transaction: { description: string } };
|
||||
expect(body.transaction.description).toBe("Updated description");
|
||||
});
|
||||
|
||||
it("DELETE /api/transactions/:id — tenant isolation: household B cannot delete household A transaction", async () => {
|
||||
// ctx2 tries to delete a transaction that belongs to ctx's household
|
||||
const res = await app.request(`/api/transactions/${createdTransactionId}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(ctx2.token, ctx2.householdId),
|
||||
});
|
||||
// Must be 404 — not the transaction's household → no result → 404
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("DELETE /api/transactions/:id — deletes own transaction", async () => {
|
||||
const res = await app.request(`/api/transactions/${createdTransactionId}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(ctx.token, ctx.householdId),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
5
apps/server/src/__tests__/setup.ts
Normal file
5
apps/server/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { config } from "dotenv";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
// Load .env file relative to the server app root
|
||||
config({ path: resolve(import.meta.dir, "../../.env") });
|
||||
@@ -1,8 +1,11 @@
|
||||
import { auth } from "@haushaltsApp/auth";
|
||||
import { env } from "@haushaltsApp/env/server";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import { registerRoutes } from "./routes";
|
||||
import { shoppingWsHandlers } from "./ws/shopping-ws";
|
||||
import { db, eq } from "@haushaltsApp/db";
|
||||
import { session as sessionTable } from "@haushaltsApp/db/schema";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -11,16 +14,53 @@ app.use(
|
||||
"/*",
|
||||
cors({
|
||||
origin: env.CORS_ORIGIN,
|
||||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization", "x-household-id"],
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
||||
registerRoutes(app);
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.text("OK");
|
||||
});
|
||||
// When running under Bun directly (not imported as a module for tests),
|
||||
// start Bun.serve with WebSocket support.
|
||||
if (typeof Bun !== "undefined" && !process.env.BUN_TEST) {
|
||||
Bun.serve({
|
||||
port: 3000,
|
||||
hostname: "0.0.0.0",
|
||||
websocket: shoppingWsHandlers,
|
||||
async fetch(req: Request, server) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === "/api/shopping-lists/ws") {
|
||||
const token = url.searchParams.get("token") ?? "";
|
||||
const householdId = url.searchParams.get("householdId") ?? "";
|
||||
|
||||
if (!householdId) {
|
||||
return new Response("Missing householdId", { status: 400 });
|
||||
}
|
||||
|
||||
const rawToken = token.includes(".") ? token.split(".")[0] : token;
|
||||
if (!rawToken) return new Response("Unauthorized", { status: 401 });
|
||||
|
||||
const sessionRow = await db.query.session.findFirst({
|
||||
where: eq(sessionTable.token, rawToken),
|
||||
with: { user: true },
|
||||
});
|
||||
|
||||
if (!sessionRow?.user || sessionRow.expiresAt < new Date()) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: { householdId, userId: sessionRow.user.id },
|
||||
});
|
||||
if (upgraded) return undefined as unknown as Response;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
return app.fetch(req);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
30
apps/server/src/middleware/auth.middleware.ts
Normal file
30
apps/server/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { auth } from "@haushaltsApp/auth";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
|
||||
type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
|
||||
|
||||
export type AuthVariables = {
|
||||
user: NonNullable<Session>["user"] | null;
|
||||
session: NonNullable<Session>["session"] | null;
|
||||
};
|
||||
|
||||
export const authMiddleware = createMiddleware<{ Variables: AuthVariables }>(
|
||||
async (c, next) => {
|
||||
const result = await auth.api.getSession({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
c.set("user", result?.user ?? null);
|
||||
c.set("session", result?.session ?? null);
|
||||
await next();
|
||||
}
|
||||
);
|
||||
|
||||
export const requireAuth = createMiddleware<{ Variables: AuthVariables }>(
|
||||
async (c, next) => {
|
||||
const user = c.get("user");
|
||||
if (!user) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
await next();
|
||||
}
|
||||
);
|
||||
26
apps/server/src/middleware/plan.middleware.ts
Normal file
26
apps/server/src/middleware/plan.middleware.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PLAN_FEATURES } from "@haushaltsApp/shared/constants/plans";
|
||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
|
||||
export type PlanVariables = {
|
||||
plan: keyof typeof PLAN_FEATURES;
|
||||
};
|
||||
|
||||
export const planMiddleware: MiddlewareHandler<{ Variables: PlanVariables }> =
|
||||
createMiddleware(async (c: Context, next: Next) => {
|
||||
// TODO: Load from DB based on householdId
|
||||
// For now default to free plan
|
||||
c.set("plan", "free" as const);
|
||||
await next();
|
||||
});
|
||||
|
||||
export function requireFeature(feature: keyof (typeof PLAN_FEATURES)["pro"]): MiddlewareHandler {
|
||||
return createMiddleware(async (c: Context, next: Next) => {
|
||||
const plan = (c.get("plan") as keyof typeof PLAN_FEATURES) ?? "free";
|
||||
const features = PLAN_FEATURES[plan] as Record<string, unknown>;
|
||||
if (!features[feature]) {
|
||||
return c.json({ error: "Feature not available on current plan", feature }, 403);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
23
apps/server/src/middleware/tenant.middleware.ts
Normal file
23
apps/server/src/middleware/tenant.middleware.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createMiddleware } from "hono/factory";
|
||||
|
||||
export type TenantVariables = {
|
||||
householdId: string | null;
|
||||
};
|
||||
|
||||
export const tenantMiddleware = createMiddleware<{
|
||||
Variables: TenantVariables;
|
||||
}>(async (c, next) => {
|
||||
const householdId = c.req.header("x-household-id") ?? null;
|
||||
c.set("householdId", householdId);
|
||||
await next();
|
||||
});
|
||||
|
||||
export const requireHousehold = createMiddleware<{
|
||||
Variables: TenantVariables;
|
||||
}>(async (c, next) => {
|
||||
const householdId = c.get("householdId");
|
||||
if (!householdId) {
|
||||
return c.json({ error: "No household selected" }, 400);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
6
apps/server/src/routes/auth.routes.ts
Normal file
6
apps/server/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { auth } from "@haushaltsApp/auth";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export const authRoutes = new Hono();
|
||||
|
||||
authRoutes.on(["GET", "POST"], "/*", (c) => auth.handler(c.req.raw));
|
||||
96
apps/server/src/routes/categories.routes.ts
Normal file
96
apps/server/src/routes/categories.routes.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
import {
|
||||
getCategoriesByHousehold,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
} from "../services/category.service";
|
||||
import { db, eq } from "@haushaltsApp/db";
|
||||
import { categories } from "@haushaltsApp/db/schema";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const categoryRoutes = new Hono<{ Variables: Variables }>();
|
||||
|
||||
categoryRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
const CreateCategorySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
icon: z.string().nullable().optional(),
|
||||
color: z.string().nullable().optional(),
|
||||
type: z.enum(["income", "expense"]),
|
||||
});
|
||||
|
||||
const UpdateCategorySchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
icon: z.string().nullable().optional(),
|
||||
color: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
// GET /api/categories — list categories for household, optional ?type= filter
|
||||
categoryRoutes.get("/", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const typeFilter = c.req.query("type") as "income" | "expense" | undefined;
|
||||
let data = await getCategoriesByHousehold(householdId);
|
||||
if (typeFilter === "income" || typeFilter === "expense") {
|
||||
data = data.filter((cat) => cat.type === typeFilter);
|
||||
}
|
||||
return c.json({ categories: data });
|
||||
});
|
||||
|
||||
// POST /api/categories — create category
|
||||
categoryRoutes.post("/", zValidator("json", CreateCategorySchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const input = c.req.valid("json");
|
||||
const cat = await createCategory(householdId, input);
|
||||
return c.json({ category: cat }, 201);
|
||||
});
|
||||
|
||||
// PATCH /api/categories/:id — update category
|
||||
categoryRoutes.patch("/:id", zValidator("json", UpdateCategorySchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
const cat = await updateCategory(householdId, id, input);
|
||||
if (!cat) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ category: cat });
|
||||
});
|
||||
|
||||
// DELETE /api/categories/:id — delete category
|
||||
categoryRoutes.delete("/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
|
||||
// Check if category is a default category
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.id, id));
|
||||
|
||||
if (!existing || existing.householdId !== householdId) {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
|
||||
if (existing.isDefault) {
|
||||
return c.json(
|
||||
{ error: "Standardkategorien können nicht gelöscht werden", usageCount: 0 },
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
const result = await deleteCategory(householdId, id);
|
||||
if (!result.deleted) {
|
||||
return c.json(
|
||||
{
|
||||
error: `${result.usageCount} Buchungen verwenden diese Kategorie`,
|
||||
usageCount: result.usageCount,
|
||||
},
|
||||
409
|
||||
);
|
||||
}
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
61
apps/server/src/routes/children.routes.ts
Normal file
61
apps/server/src/routes/children.routes.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
import { CreateChildSchema, UpdateChildSchema } from "@haushaltsApp/shared/schemas/children.schema";
|
||||
import {
|
||||
getChildren,
|
||||
getChildById,
|
||||
createChild,
|
||||
updateChild,
|
||||
deleteChild,
|
||||
} from "../services/children.service";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const childrenRoutes = new Hono<{ Variables: Variables }>();
|
||||
|
||||
childrenRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
// GET /api/children — list all children for household
|
||||
childrenRoutes.get("/", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const data = await getChildren(householdId);
|
||||
return c.json({ children: data });
|
||||
});
|
||||
|
||||
// GET /api/children/:id
|
||||
childrenRoutes.get("/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const child = await getChildById(id, householdId);
|
||||
if (!child) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ child });
|
||||
});
|
||||
|
||||
// POST /api/children
|
||||
childrenRoutes.post("/", zValidator("json", CreateChildSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const input = c.req.valid("json");
|
||||
const child = await createChild(householdId, input);
|
||||
return c.json({ child }, 201);
|
||||
});
|
||||
|
||||
// PATCH /api/children/:id
|
||||
childrenRoutes.patch("/:id", zValidator("json", UpdateChildSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
const child = await updateChild(id, householdId, input);
|
||||
if (!child) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ child });
|
||||
});
|
||||
|
||||
// DELETE /api/children/:id
|
||||
childrenRoutes.delete("/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const child = await deleteChild(id, householdId);
|
||||
if (!child) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ child });
|
||||
});
|
||||
75
apps/server/src/routes/debts.routes.ts
Normal file
75
apps/server/src/routes/debts.routes.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
import { CreateDebtSchema, CreateDebtPaymentSchema } from "@haushaltsApp/shared/schemas/debt.schema";
|
||||
import {
|
||||
getDebts,
|
||||
getClaims,
|
||||
createDebt,
|
||||
deleteDebt,
|
||||
getDebtPayments,
|
||||
createDebtPayment,
|
||||
} from "../services/debt.service";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const debtRoutes = new Hono<{ Variables: Variables }>();
|
||||
|
||||
debtRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
// GET /api/debts
|
||||
debtRoutes.get("/", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const data = await getDebts(householdId, user.id);
|
||||
return c.json({ debts: data });
|
||||
});
|
||||
|
||||
// POST /api/debts
|
||||
debtRoutes.post("/", zValidator("json", CreateDebtSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const input = c.req.valid("json");
|
||||
const debt = await createDebt(householdId, user.id, input);
|
||||
return c.json({ debt }, 201);
|
||||
});
|
||||
|
||||
// DELETE /api/debts/:id
|
||||
debtRoutes.delete("/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const { id } = c.req.param();
|
||||
const ok = await deleteDebt(id, householdId, user.id);
|
||||
if (!ok) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/debts/claims — debts where I am the creditor
|
||||
debtRoutes.get("/claims", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const data = await getClaims(householdId, user.id);
|
||||
return c.json({ debts: data });
|
||||
});
|
||||
|
||||
// GET /api/debts/:id/payments
|
||||
debtRoutes.get("/:id/payments", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const payments = await getDebtPayments(id, householdId);
|
||||
return c.json({ payments });
|
||||
});
|
||||
|
||||
// POST /api/debts/payments
|
||||
debtRoutes.post("/payments", zValidator("json", CreateDebtPaymentSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const input = c.req.valid("json");
|
||||
try {
|
||||
const result = await createDebtPayment(householdId, user.id, input);
|
||||
return c.json(result, 201);
|
||||
} catch {
|
||||
return c.json({ error: "Debt not found" }, 404);
|
||||
}
|
||||
});
|
||||
131
apps/server/src/routes/fixed-costs.routes.ts
Normal file
131
apps/server/src/routes/fixed-costs.routes.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
import {
|
||||
CreateFixedCostSchema,
|
||||
UpdateFixedCostSchema,
|
||||
CreateTransferLineItemSchema,
|
||||
UpdateTransferLineItemSchema,
|
||||
CreateMonthlyTransferSchema,
|
||||
} from "@haushaltsApp/shared/schemas/fixed-costs.schema";
|
||||
import {
|
||||
getFixedCosts,
|
||||
createFixedCost,
|
||||
updateFixedCost,
|
||||
deleteFixedCost,
|
||||
getTransferLineItems,
|
||||
createTransferLineItem,
|
||||
updateTransferLineItem,
|
||||
deleteTransferLineItem,
|
||||
getMonthlyTransfers,
|
||||
createMonthlyTransfer,
|
||||
getSettlementV2,
|
||||
getNettoMonth,
|
||||
} from "../services/fixed-costs.service";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const fixedCostsRoutes = new Hono<{ Variables: Variables }>();
|
||||
fixedCostsRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
// ── Fixed Costs ───────────────────────────────────────────────────────────────
|
||||
|
||||
fixedCostsRoutes.get("/", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const data = await getFixedCosts(householdId);
|
||||
return c.json({ fixedCosts: data });
|
||||
});
|
||||
|
||||
fixedCostsRoutes.post("/", zValidator("json", CreateFixedCostSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const input = c.req.valid("json");
|
||||
const fixedCost = await createFixedCost(householdId, input);
|
||||
return c.json({ fixedCost }, 201);
|
||||
});
|
||||
|
||||
fixedCostsRoutes.patch("/:id", zValidator("json", UpdateFixedCostSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
const fixedCost = await updateFixedCost(id, householdId, input);
|
||||
if (!fixedCost) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ fixedCost });
|
||||
});
|
||||
|
||||
fixedCostsRoutes.delete("/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const ok = await deleteFixedCost(id, householdId);
|
||||
if (!ok) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Transfer Line Items ───────────────────────────────────────────────────────
|
||||
|
||||
fixedCostsRoutes.get("/line-items", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const data = await getTransferLineItems(householdId);
|
||||
return c.json({ lineItems: data });
|
||||
});
|
||||
|
||||
fixedCostsRoutes.post("/line-items", zValidator("json", CreateTransferLineItemSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const input = c.req.valid("json");
|
||||
const lineItem = await createTransferLineItem(householdId, input);
|
||||
return c.json({ lineItem }, 201);
|
||||
});
|
||||
|
||||
fixedCostsRoutes.patch("/line-items/:id", zValidator("json", UpdateTransferLineItemSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
const lineItem = await updateTransferLineItem(id, householdId, input);
|
||||
if (!lineItem) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ lineItem });
|
||||
});
|
||||
|
||||
fixedCostsRoutes.delete("/line-items/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const ok = await deleteTransferLineItem(id, householdId);
|
||||
if (!ok) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Monthly Transfers ─────────────────────────────────────────────────────────
|
||||
|
||||
fixedCostsRoutes.get("/monthly-transfers/:month", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { month } = c.req.param();
|
||||
const transfers = await getMonthlyTransfers(householdId, month);
|
||||
return c.json({ transfers });
|
||||
});
|
||||
|
||||
fixedCostsRoutes.post("/monthly-transfers", zValidator("json", CreateMonthlyTransferSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const input = c.req.valid("json");
|
||||
const transfer = await createMonthlyTransfer(householdId, user.id, input);
|
||||
return c.json({ transfer }, 201);
|
||||
});
|
||||
|
||||
// ── Settlement V2 ─────────────────────────────────────────────────────────────
|
||||
|
||||
fixedCostsRoutes.get("/settlement/:month", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const { month } = c.req.param();
|
||||
const settlement = await getSettlementV2(householdId, user.id, month);
|
||||
return c.json({ settlement });
|
||||
});
|
||||
|
||||
// ── Netto Month ───────────────────────────────────────────────────────────────
|
||||
|
||||
fixedCostsRoutes.get("/netto/:month", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const { month } = c.req.param();
|
||||
const netto = await getNettoMonth(householdId, user.id, month);
|
||||
return c.json({ netto });
|
||||
});
|
||||
27
apps/server/src/routes/household-settings.routes.ts
Normal file
27
apps/server/src/routes/household-settings.routes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
import { UpdateHouseholdSettingsSchema } from "@haushaltsApp/shared/schemas/household-settings.schema";
|
||||
import {
|
||||
getOrCreateHouseholdSettings,
|
||||
updateHouseholdSettings,
|
||||
} from "../services/household-settings.service";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const householdSettingsRoutes = new Hono<{ Variables: Variables }>();
|
||||
householdSettingsRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
householdSettingsRoutes.get("/", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const settings = await getOrCreateHouseholdSettings(householdId);
|
||||
return c.json({ settings });
|
||||
});
|
||||
|
||||
householdSettingsRoutes.patch("/", zValidator("json", UpdateHouseholdSettingsSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const input = c.req.valid("json");
|
||||
const settings = await updateHouseholdSettings(householdId, input);
|
||||
return c.json({ settings });
|
||||
});
|
||||
216
apps/server/src/routes/households.routes.ts
Normal file
216
apps/server/src/routes/households.routes.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { db, eq, and, gte, lte, sql } from "@haushaltsApp/db";
|
||||
import { households, member, transactions, user, invitation } from "@haushaltsApp/db/schema";
|
||||
import { auth } from "@haushaltsApp/auth";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { seedDefaultCategories, getCategoriesByHousehold } from "../services/category.service";
|
||||
import { getOrCreateHouseholdSettings } from "../services/household-settings.service";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const householdRoutes = new Hono<{ Variables: Variables }>();
|
||||
|
||||
householdRoutes.use("/*", authMiddleware, requireAuth);
|
||||
|
||||
// POST /api/households/setup — called after organization.create() in onboarding
|
||||
// Upserts the households row (using org ID), then seeds default categories
|
||||
householdRoutes.post("/setup", tenantMiddleware, requireHousehold, async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string; name: string };
|
||||
|
||||
// Ensure the households row exists (org ID == household ID)
|
||||
await db
|
||||
.insert(households)
|
||||
.values({ id: householdId, name: user.name, ownerId: user.id })
|
||||
.onConflictDoNothing();
|
||||
|
||||
const cats = await seedDefaultCategories(householdId);
|
||||
// Ensure household_settings row exists with defaults
|
||||
await getOrCreateHouseholdSettings(householdId);
|
||||
return c.json({ categories: cats }, 201);
|
||||
});
|
||||
|
||||
// POST /api/households/repair — no x-household-id needed
|
||||
// Finds the user's first organization and upserts the households row
|
||||
householdRoutes.post("/repair", async (c) => {
|
||||
const user = c.get("user") as { id: string; name: string };
|
||||
|
||||
// Find first organization this user belongs to
|
||||
const membership = await db.query.member.findFirst({
|
||||
where: eq(member.userId, user.id),
|
||||
with: { organization: true },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
return c.json({ error: "No organization found — create a household first" }, 404);
|
||||
}
|
||||
|
||||
const org = membership.organization;
|
||||
|
||||
await db
|
||||
.insert(households)
|
||||
.values({ id: org.id, name: org.name, ownerId: user.id })
|
||||
.onConflictDoNothing();
|
||||
|
||||
return c.json({ householdId: org.id, name: org.name });
|
||||
});
|
||||
|
||||
// GET /api/households/categories — list categories for current household
|
||||
householdRoutes.get("/categories", tenantMiddleware, requireHousehold, async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const cats = await getCategoriesByHousehold(householdId);
|
||||
return c.json({ categories: cats });
|
||||
});
|
||||
|
||||
// GET /api/households — list all households the user is a member of
|
||||
householdRoutes.get("/", async (c) => {
|
||||
const orgs = await auth.api.listOrganizations({
|
||||
headers: c.req.raw.headers,
|
||||
});
|
||||
|
||||
const householdList = (orgs ?? []).map((org: { id: string; name: string; role?: string; members?: unknown[] }) => ({
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
role: org.role ?? "member",
|
||||
}));
|
||||
|
||||
return c.json({ households: householdList });
|
||||
});
|
||||
|
||||
// GET /api/households/members — list members and pending invitations
|
||||
householdRoutes.get("/members", tenantMiddleware, requireHousehold, async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
|
||||
const members = await db
|
||||
.select({
|
||||
userId: member.userId,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: member.role,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, householdId));
|
||||
|
||||
const pendingInvitations = await db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
status: invitation.status,
|
||||
expiresAt: invitation.expiresAt,
|
||||
createdAt: invitation.createdAt,
|
||||
})
|
||||
.from(invitation)
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.organizationId, householdId),
|
||||
eq(invitation.status, "pending"),
|
||||
),
|
||||
);
|
||||
|
||||
return c.json({ members, pendingInvitations });
|
||||
});
|
||||
|
||||
// DELETE /api/households/invitations/:id — revoke a pending invitation
|
||||
householdRoutes.delete("/invitations/:id", tenantMiddleware, requireHousehold, async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const invitationId = c.req.param("id");
|
||||
|
||||
await db
|
||||
.delete(invitation)
|
||||
.where(
|
||||
and(
|
||||
eq(invitation.id, invitationId),
|
||||
eq(invitation.organizationId, householdId),
|
||||
eq(invitation.status, "pending"),
|
||||
),
|
||||
);
|
||||
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/households/settlement?month=YYYY-MM — monthly settlement calculation
|
||||
householdRoutes.get("/settlement", tenantMiddleware, requireHousehold, async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const monthParam = c.req.query("month");
|
||||
|
||||
// Parse month — default to current month
|
||||
let year: number;
|
||||
let monthNum: number;
|
||||
if (monthParam && /^\d{4}-\d{2}$/.test(monthParam)) {
|
||||
const parts = monthParam.split("-");
|
||||
year = parseInt(parts[0]!, 10);
|
||||
monthNum = parseInt(parts[1]!, 10);
|
||||
} else {
|
||||
const now = new Date();
|
||||
year = now.getFullYear();
|
||||
monthNum = now.getMonth() + 1;
|
||||
}
|
||||
|
||||
const from = `${year}-${String(monthNum).padStart(2, "0")}-01`;
|
||||
const lastDay = new Date(year, monthNum, 0).getDate();
|
||||
const to = `${year}-${String(monthNum).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
|
||||
const month = `${year}-${String(monthNum).padStart(2, "0")}`;
|
||||
|
||||
// Load all members of the household (organization) with their user names
|
||||
const members = await db
|
||||
.select({
|
||||
userId: member.userId,
|
||||
name: user.name,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, householdId));
|
||||
|
||||
const memberCount = members.length;
|
||||
|
||||
// Load all household-scope expense transactions for the month
|
||||
const expenseRows = await db
|
||||
.select({
|
||||
userId: transactions.userId,
|
||||
total: sql<string>`sum(${transactions.amount}::numeric)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.scope, "household"),
|
||||
eq(transactions.type, "expense"),
|
||||
gte(transactions.date, from),
|
||||
lte(transactions.date, to),
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.userId);
|
||||
|
||||
// Build paid map
|
||||
const paidByUser: Record<string, number> = {};
|
||||
let totalExpenses = 0;
|
||||
for (const row of expenseRows) {
|
||||
const amount = Number(row.total ?? 0);
|
||||
paidByUser[row.userId] = amount;
|
||||
totalExpenses += amount;
|
||||
}
|
||||
|
||||
const perMember = memberCount > 0 ? totalExpenses / memberCount : 0;
|
||||
|
||||
const membersResult = members.map((m) => {
|
||||
const paid = paidByUser[m.userId] ?? 0;
|
||||
const owes = perMember - paid;
|
||||
return {
|
||||
userId: m.userId,
|
||||
name: m.name,
|
||||
paid,
|
||||
owes,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json({
|
||||
month,
|
||||
totalExpenses,
|
||||
memberCount,
|
||||
perMember,
|
||||
members: membersResult,
|
||||
});
|
||||
});
|
||||
38
apps/server/src/routes/index.ts
Normal file
38
apps/server/src/routes/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Hono } from "hono";
|
||||
import { authRoutes } from "./auth.routes";
|
||||
import { categoryRoutes } from "./categories.routes";
|
||||
import { childrenRoutes } from "./children.routes";
|
||||
import { debtRoutes } from "./debts.routes";
|
||||
import { fixedCostsRoutes } from "./fixed-costs.routes";
|
||||
import { householdRoutes } from "./households.routes";
|
||||
import { householdSettingsRoutes } from "./household-settings.routes";
|
||||
import { inviteRoutes } from "./invite.routes";
|
||||
import { monthsRoutes } from "./months.routes";
|
||||
import { shoppingListRoutes } from "./shopping-list.routes";
|
||||
import { shoppingRoutes } from "./shopping.routes";
|
||||
import { subscriptionRoutes } from "./subscriptions.routes";
|
||||
import { transactionRoutes } from "./transactions.routes";
|
||||
import { tripsRoutes } from "./trips.routes";
|
||||
import { scannerRoutes } from "./scanner.routes";
|
||||
|
||||
export function registerRoutes(app: Hono) {
|
||||
app.route("/api/auth", authRoutes);
|
||||
app.route("/api/households/invite", inviteRoutes);
|
||||
app.route("/api/households", householdRoutes);
|
||||
app.route("/api/household-settings", householdSettingsRoutes);
|
||||
app.route("/api/months", monthsRoutes);
|
||||
app.route("/api/transactions", transactionRoutes);
|
||||
app.route("/api/categories", categoryRoutes);
|
||||
app.route("/api/children", childrenRoutes);
|
||||
app.route("/api/debts", debtRoutes);
|
||||
app.route("/api/fixed-costs", fixedCostsRoutes);
|
||||
app.route("/api/shopping-lists", shoppingListRoutes);
|
||||
app.route("/api/shopping", shoppingRoutes);
|
||||
app.route("/api/subscriptions", subscriptionRoutes);
|
||||
app.route("/api/trips", tripsRoutes);
|
||||
app.route("/api/scanner", scannerRoutes);
|
||||
|
||||
app.get("/health", (c) => {
|
||||
return c.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
}
|
||||
147
apps/server/src/routes/invite.routes.ts
Normal file
147
apps/server/src/routes/invite.routes.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { db, eq, and, isNull } from "@haushaltsApp/db";
|
||||
import { householdInvitations, households, member } from "@haushaltsApp/db/schema";
|
||||
import { joinWithCodeSchema } from "@haushaltsApp/shared/schemas/invite.schema";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const CODE_LENGTH = 6;
|
||||
const EXPIRES_IN_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
function generateCode(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(CODE_LENGTH));
|
||||
let code = "";
|
||||
for (const byte of bytes) {
|
||||
code += CODE_ALPHABET[byte % CODE_ALPHABET.length];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export const inviteRoutes = new Hono<{ Variables: Variables }>();
|
||||
|
||||
inviteRoutes.use("/*", authMiddleware, requireAuth);
|
||||
|
||||
// POST /api/households/invite/generate — create a new invite code for current household
|
||||
inviteRoutes.post("/generate", tenantMiddleware, requireHousehold, async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const currentUser = c.get("user") as { id: string };
|
||||
const now = new Date();
|
||||
|
||||
// Invalidate any existing active (non-expired, non-used) codes for this household
|
||||
const existingActive = await db
|
||||
.select({ id: householdInvitations.id })
|
||||
.from(householdInvitations)
|
||||
.where(
|
||||
and(
|
||||
eq(householdInvitations.householdId, householdId),
|
||||
isNull(householdInvitations.usedAt),
|
||||
),
|
||||
);
|
||||
|
||||
for (const row of existingActive) {
|
||||
// Mark as expired by setting expiresAt to now (effectively invalidating)
|
||||
await db
|
||||
.update(householdInvitations)
|
||||
.set({ expiresAt: now.toISOString() })
|
||||
.where(eq(householdInvitations.id, row.id));
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const expiresAt = new Date(now.getTime() + EXPIRES_IN_MS).toISOString();
|
||||
|
||||
await db.insert(householdInvitations).values({
|
||||
id: crypto.randomUUID(),
|
||||
householdId,
|
||||
code,
|
||||
createdBy: currentUser.id,
|
||||
expiresAt,
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
|
||||
return c.json({ code, expiresAt });
|
||||
});
|
||||
|
||||
// POST /api/households/invite/join — join a household using an invite code
|
||||
inviteRoutes.post("/join", async (c) => {
|
||||
const currentUser = c.get("user") as { id: string };
|
||||
|
||||
const body = await c.req.json();
|
||||
const parsed = joinWithCodeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: "Invalid request", issues: parsed.error.issues }, 400);
|
||||
}
|
||||
|
||||
const code = parsed.data.code.toUpperCase();
|
||||
const now = new Date();
|
||||
|
||||
// Look up the invitation by code
|
||||
const invite = await db
|
||||
.select()
|
||||
.from(householdInvitations)
|
||||
.where(eq(householdInvitations.code, code))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!invite) {
|
||||
return c.json({ error: "Invalid code" }, 404);
|
||||
}
|
||||
|
||||
if (invite.usedAt !== null) {
|
||||
return c.json({ error: "Code already used" }, 409);
|
||||
}
|
||||
|
||||
if (new Date(invite.expiresAt) < now) {
|
||||
return c.json({ error: "Code expired" }, 410);
|
||||
}
|
||||
|
||||
// Check if user is already a member of that household
|
||||
const existingMembership = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.organizationId, invite.householdId),
|
||||
eq(member.userId, currentUser.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existingMembership) {
|
||||
return c.json({ error: "Already a member" }, 409);
|
||||
}
|
||||
|
||||
// Insert into member table
|
||||
await db.insert(member).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: invite.householdId,
|
||||
userId: currentUser.id,
|
||||
role: "member",
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
// Mark invitation as used
|
||||
await db
|
||||
.update(householdInvitations)
|
||||
.set({
|
||||
usedAt: now.toISOString(),
|
||||
usedBy: currentUser.id,
|
||||
})
|
||||
.where(eq(householdInvitations.id, invite.id));
|
||||
|
||||
// Get household name
|
||||
const household = await db
|
||||
.select({ id: households.id, name: households.name })
|
||||
.from(households)
|
||||
.where(eq(households.id, invite.householdId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
return c.json({
|
||||
householdId: invite.householdId,
|
||||
householdName: household?.name ?? null,
|
||||
});
|
||||
});
|
||||
66
apps/server/src/routes/months.routes.ts
Normal file
66
apps/server/src/routes/months.routes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
import {
|
||||
getMonthStatus,
|
||||
closeMonth,
|
||||
reopenMonth,
|
||||
} from "../services/month-status.service";
|
||||
import { getSettlementV2 } from "../services/fixed-costs.service";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
const CloseMonthSchema = z.object({
|
||||
finalAmount: z.number().min(0),
|
||||
toUserId: z.string().min(1),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const monthsRoutes = new Hono<{ Variables: Variables }>();
|
||||
monthsRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
// GET /api/months/:month/status
|
||||
monthsRoutes.get("/:month/status", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { month } = c.req.param();
|
||||
const status = await getMonthStatus(householdId, month);
|
||||
return c.json({ status });
|
||||
});
|
||||
|
||||
// POST /api/months/:month/close
|
||||
monthsRoutes.post("/:month/close", zValidator("json", CloseMonthSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const { month } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
|
||||
try {
|
||||
const status = await closeMonth(householdId, month, user.id, input);
|
||||
return c.json({ status }, 201);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Fehler beim Abschließen";
|
||||
return c.json({ error: message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/months/:month/reopen (API only — no UI in v1)
|
||||
monthsRoutes.post("/:month/reopen", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { month } = c.req.param();
|
||||
const status = await reopenMonth(householdId, month);
|
||||
return c.json({ status });
|
||||
});
|
||||
|
||||
// GET /api/months/:month/settlement — convenience: settlement + status in one call
|
||||
monthsRoutes.get("/:month/settlement", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const { month } = c.req.param();
|
||||
const [status, settlement] = await Promise.all([
|
||||
getMonthStatus(householdId, month),
|
||||
getSettlementV2(householdId, user.id, month),
|
||||
]);
|
||||
return c.json({ status, settlement });
|
||||
});
|
||||
102
apps/server/src/routes/scanner.routes.ts
Normal file
102
apps/server/src/routes/scanner.routes.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { scanReceiptSchema } from "@haushaltsApp/shared/schemas/scanner.schema";
|
||||
|
||||
type Variables = AuthVariables;
|
||||
|
||||
type ClaudeResponse = {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
};
|
||||
|
||||
type ReceiptData = {
|
||||
amount: number | null;
|
||||
label: string | null;
|
||||
date: string | null;
|
||||
};
|
||||
|
||||
const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages";
|
||||
const CLAUDE_MODEL = "claude-haiku-4-5-20251001";
|
||||
const OCR_PROMPT =
|
||||
"Extract from this receipt: total amount, merchant/store name, and date.\n" +
|
||||
'Reply ONLY with valid JSON, no other text:\n{"amount": 12.50, "label": "Merchant Name", "date": "2026-03-15"}\n' +
|
||||
"Use null for any field you cannot determine with confidence. Date format: YYYY-MM-DD. Amount as decimal number (not string).";
|
||||
|
||||
export const scannerRoutes = new Hono<{ Variables: Variables }>();
|
||||
|
||||
scannerRoutes.use("/*", authMiddleware, requireAuth);
|
||||
|
||||
// POST /receipt — scan a receipt image via Claude Vision
|
||||
scannerRoutes.post(
|
||||
"/receipt",
|
||||
zValidator("json", scanReceiptSchema),
|
||||
async (c) => {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) {
|
||||
return c.json({ error: "OCR service not configured" }, 503);
|
||||
}
|
||||
|
||||
const { imageBase64, mimeType } = c.req.valid("json");
|
||||
|
||||
const claudeRes = await fetch(CLAUDE_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: CLAUDE_MODEL,
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: mimeType,
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: OCR_PROMPT,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!claudeRes.ok) {
|
||||
return c.json({ error: "OCR service error" }, 502);
|
||||
}
|
||||
|
||||
const claudeData = (await claudeRes.json()) as ClaudeResponse;
|
||||
const textBlock = claudeData.content.find((block) => block.type === "text");
|
||||
const rawText = textBlock?.text ?? "";
|
||||
|
||||
// Extract JSON from response — Claude sometimes wraps it in markdown or adds extra text
|
||||
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
return c.json({ error: "Could not parse receipt" }, 422);
|
||||
}
|
||||
|
||||
let parsed: ReceiptData;
|
||||
try {
|
||||
parsed = JSON.parse(jsonMatch[0]) as ReceiptData;
|
||||
} catch {
|
||||
return c.json({ error: "Could not parse receipt" }, 422);
|
||||
}
|
||||
|
||||
const amount = typeof parsed.amount === "number" ? parsed.amount : null;
|
||||
const label = typeof parsed.label === "string" ? parsed.label : null;
|
||||
const date = typeof parsed.date === "string" ? parsed.date : null;
|
||||
|
||||
const confidence = [amount, label, date].filter((v) => v !== null).length / 3;
|
||||
|
||||
return c.json({ amount, label, date, confidence });
|
||||
},
|
||||
);
|
||||
113
apps/server/src/routes/shopping-list.routes.ts
Normal file
113
apps/server/src/routes/shopping-list.routes.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { db, eq, and, desc } from "@haushaltsApp/db";
|
||||
import { shoppingLists, shoppingListItems } from "@haushaltsApp/db/schema";
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const shoppingListRoutes = new Hono<{ Variables: Variables }>();
|
||||
shoppingListRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
async function getOrCreateActiveList(householdId: string) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(shoppingLists)
|
||||
.where(and(eq(shoppingLists.householdId, householdId), eq(shoppingLists.isActive, true)))
|
||||
.limit(1);
|
||||
if (existing[0]) return existing[0];
|
||||
const [list] = await db
|
||||
.insert(shoppingLists)
|
||||
.values({ householdId, name: "Einkaufsliste", isActive: true })
|
||||
.returning();
|
||||
return list!;
|
||||
}
|
||||
|
||||
// GET /api/shopping-lists/items
|
||||
shoppingListRoutes.get("/items", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const list = await getOrCreateActiveList(householdId);
|
||||
const items = await db
|
||||
.select()
|
||||
.from(shoppingListItems)
|
||||
.where(eq(shoppingListItems.listId, list.id))
|
||||
.orderBy(desc(shoppingListItems.createdAt));
|
||||
return c.json({ items, listId: list.id });
|
||||
});
|
||||
|
||||
// POST /api/shopping-lists/items
|
||||
shoppingListRoutes.post(
|
||||
"/items",
|
||||
zValidator("json", z.object({ name: z.string().min(1).max(200) })),
|
||||
async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const { name } = c.req.valid("json");
|
||||
const list = await getOrCreateActiveList(householdId);
|
||||
const [item] = await db
|
||||
.insert(shoppingListItems)
|
||||
.values({ listId: list.id, addedByUserId: user.id, name })
|
||||
.returning();
|
||||
return c.json({ item }, 201);
|
||||
},
|
||||
);
|
||||
|
||||
// PATCH /api/shopping-lists/items/:id
|
||||
shoppingListRoutes.patch(
|
||||
"/items/:id",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
isChecked: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const { id } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
const list = await getOrCreateActiveList(householdId);
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (input.name !== undefined) updateValues.name = input.name;
|
||||
if (input.isChecked !== undefined) {
|
||||
updateValues.isChecked = input.isChecked;
|
||||
updateValues.checkedByUserId = input.isChecked ? user.id : null;
|
||||
updateValues.checkedAt = input.isChecked ? new Date() : null;
|
||||
}
|
||||
const [item] = await db
|
||||
.update(shoppingListItems)
|
||||
.set(updateValues)
|
||||
.where(and(eq(shoppingListItems.id, id), eq(shoppingListItems.listId, list.id)))
|
||||
.returning();
|
||||
if (!item) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ item });
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/shopping-lists/items/checked — delete all checked items
|
||||
// IMPORTANT: must be registered BEFORE /items/:id to avoid being matched as an id
|
||||
shoppingListRoutes.delete("/items/checked", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const list = await getOrCreateActiveList(householdId);
|
||||
const deleted = await db
|
||||
.delete(shoppingListItems)
|
||||
.where(and(eq(shoppingListItems.listId, list.id), eq(shoppingListItems.isChecked, true)))
|
||||
.returning();
|
||||
return c.json({ deleted: deleted.length });
|
||||
});
|
||||
|
||||
// DELETE /api/shopping-lists/items/:id
|
||||
shoppingListRoutes.delete("/items/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const list = await getOrCreateActiveList(householdId);
|
||||
const [item] = await db
|
||||
.delete(shoppingListItems)
|
||||
.where(and(eq(shoppingListItems.id, id), eq(shoppingListItems.listId, list.id)))
|
||||
.returning();
|
||||
if (!item) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
83
apps/server/src/routes/shopping.routes.ts
Normal file
83
apps/server/src/routes/shopping.routes.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { db, eq, and, isNotNull } from "@haushaltsApp/db";
|
||||
import { shoppingItems } from "@haushaltsApp/db/schema";
|
||||
import { Hono } from "hono";
|
||||
import type { ShoppingServerEvent } from "@haushaltsApp/shared/schemas/shopping.schema";
|
||||
import { addShoppingItemSchema } from "@haushaltsApp/shared/schemas/shopping.schema";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { tenantMiddleware, requireHousehold, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
import { broadcast } from "../ws/shopping-ws";
|
||||
import {
|
||||
getShoppingItems,
|
||||
addShoppingItem,
|
||||
checkShoppingItem,
|
||||
uncheckShoppingItem,
|
||||
deleteShoppingItem,
|
||||
} from "../services/shopping.service";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const shoppingRoutes = new Hono<{ Variables: Variables }>();
|
||||
shoppingRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
// GET /api/shopping — list all items for household
|
||||
shoppingRoutes.get("/", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const items = await getShoppingItems(householdId);
|
||||
return c.json({ items });
|
||||
});
|
||||
|
||||
// POST /api/shopping — add an item
|
||||
shoppingRoutes.post("/", zValidator("json", addShoppingItemSchema), async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const { label, quantity } = c.req.valid("json");
|
||||
const item = await addShoppingItem(householdId, user.id, label, quantity);
|
||||
broadcast(householdId, { type: "item:added", item } satisfies ShoppingServerEvent);
|
||||
return c.json({ item }, 201);
|
||||
});
|
||||
|
||||
// PATCH /api/shopping/:id/check
|
||||
shoppingRoutes.patch("/:id/check", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const id = c.req.param("id");
|
||||
const item = await checkShoppingItem(id, householdId, user.id);
|
||||
if (!item) return c.json({ error: "Not found" }, 404);
|
||||
broadcast(householdId, {
|
||||
type: "item:checked",
|
||||
itemId: item.id,
|
||||
checkedBy: item.checkedBy!,
|
||||
checkedAt: item.checkedAt!,
|
||||
} satisfies ShoppingServerEvent);
|
||||
return c.json({ item });
|
||||
});
|
||||
|
||||
// PATCH /api/shopping/:id/uncheck
|
||||
shoppingRoutes.patch("/:id/uncheck", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const id = c.req.param("id");
|
||||
const item = await uncheckShoppingItem(id, householdId);
|
||||
if (!item) return c.json({ error: "Not found" }, 404);
|
||||
broadcast(householdId, { type: "item:unchecked", itemId: id } satisfies ShoppingServerEvent);
|
||||
return c.json({ item });
|
||||
});
|
||||
|
||||
// DELETE /api/shopping/checked — clear all checked items (must be before /:id)
|
||||
shoppingRoutes.delete("/checked", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
await db
|
||||
.delete(shoppingItems)
|
||||
.where(and(eq(shoppingItems.householdId, householdId), isNotNull(shoppingItems.checkedBy)));
|
||||
broadcast(householdId, { type: "item:cleared" } satisfies ShoppingServerEvent);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/shopping/:id
|
||||
shoppingRoutes.delete("/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const id = c.req.param("id");
|
||||
await deleteShoppingItem(id, householdId);
|
||||
broadcast(householdId, { type: "item:deleted", itemId: id } satisfies ShoppingServerEvent);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
10
apps/server/src/routes/subscriptions.routes.ts
Normal file
10
apps/server/src/routes/subscriptions.routes.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth } from "../middleware/auth.middleware";
|
||||
|
||||
export const subscriptionRoutes = new Hono();
|
||||
|
||||
subscriptionRoutes.use("/*", authMiddleware, requireAuth);
|
||||
|
||||
subscriptionRoutes.get("/", async (c) => {
|
||||
return c.json({ subscription: null, message: "TODO: implement Stripe integration" });
|
||||
});
|
||||
118
apps/server/src/routes/transactions.routes.ts
Normal file
118
apps/server/src/routes/transactions.routes.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { requireHousehold, tenantMiddleware, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
import {
|
||||
activateFixedTransactions,
|
||||
carryOverBalance,
|
||||
createTransaction,
|
||||
deleteTransaction,
|
||||
getTransactionById,
|
||||
getTransactions,
|
||||
getTransactionSummary,
|
||||
updateTransaction,
|
||||
} from "../services/transaction.service";
|
||||
import {
|
||||
CreateTransactionSchema,
|
||||
TransactionFiltersSchema,
|
||||
UpdateTransactionSchema,
|
||||
} from "@haushaltsApp/shared/schemas/transaction";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const transactionRoutes = new Hono<{ Variables: Variables }>();
|
||||
|
||||
transactionRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
transactionRoutes.get(
|
||||
"/",
|
||||
zValidator("query", TransactionFiltersSchema),
|
||||
async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const filters = c.req.valid("query");
|
||||
const data = await getTransactions(householdId, user.id, filters);
|
||||
return c.json({ transactions: data });
|
||||
},
|
||||
);
|
||||
|
||||
transactionRoutes.get("/summary", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const monthParam = c.req.query("month");
|
||||
const scope = c.req.query("scope") as "household" | "private" | "child" | undefined;
|
||||
const month = monthParam ? new Date(monthParam) : new Date();
|
||||
const summary = await getTransactionSummary(householdId, user.id, month, scope);
|
||||
return c.json(summary);
|
||||
});
|
||||
|
||||
// POST /api/transactions/activate-fixed
|
||||
transactionRoutes.post("/activate-fixed", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const body = await c.req.json<{ month: string; scope: "household" | "private" | "child"; childId?: string }>();
|
||||
const result = await activateFixedTransactions(householdId, user.id, body.month, body.scope, body.childId);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// POST /api/transactions/carry-over
|
||||
transactionRoutes.post("/carry-over", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const body = await c.req.json<{
|
||||
fromMonth: string;
|
||||
toMonth: string;
|
||||
scope: "household" | "private" | "child";
|
||||
childId?: string;
|
||||
}>();
|
||||
|
||||
const result = await carryOverBalance(householdId, user.id, body.fromMonth, body.toMonth, body.scope, body.childId);
|
||||
if (result.alreadyExists) {
|
||||
return c.json({ error: "Für diesen Monat gibt es bereits einen Übertrag" }, 409);
|
||||
}
|
||||
if (!result.transaction) {
|
||||
return c.json({ error: "Saldo ist ausgeglichen — kein Übertrag nötig" }, 422);
|
||||
}
|
||||
return c.json({ transaction: result.transaction }, 201);
|
||||
});
|
||||
|
||||
transactionRoutes.get("/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const transaction = await getTransactionById(id, householdId);
|
||||
if (!transaction) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ transaction });
|
||||
});
|
||||
|
||||
transactionRoutes.post(
|
||||
"/",
|
||||
zValidator("json", CreateTransactionSchema),
|
||||
async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const user = c.get("user") as { id: string };
|
||||
const input = c.req.valid("json");
|
||||
const transaction = await createTransaction(householdId, user.id, input);
|
||||
return c.json({ transaction }, 201);
|
||||
},
|
||||
);
|
||||
|
||||
transactionRoutes.patch(
|
||||
"/:id",
|
||||
zValidator("json", UpdateTransactionSchema),
|
||||
async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
const transaction = await updateTransaction(id, householdId, input);
|
||||
if (!transaction) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ transaction });
|
||||
},
|
||||
);
|
||||
|
||||
transactionRoutes.delete("/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const transaction = await deleteTransaction(id, householdId);
|
||||
if (!transaction) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ transaction });
|
||||
});
|
||||
156
apps/server/src/routes/trips.routes.ts
Normal file
156
apps/server/src/routes/trips.routes.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import { authMiddleware, requireAuth, type AuthVariables } from "../middleware/auth.middleware";
|
||||
import { requireHousehold, tenantMiddleware, type TenantVariables } from "../middleware/tenant.middleware";
|
||||
import {
|
||||
getTrips,
|
||||
createTrip,
|
||||
updateTrip,
|
||||
deleteTrip,
|
||||
completeTrip,
|
||||
getTripExpenses,
|
||||
createTripExpense,
|
||||
updateTripExpense,
|
||||
deleteTripExpense,
|
||||
getTripSummary,
|
||||
getTripSettlementPreview,
|
||||
} from "../services/trips.service";
|
||||
import {
|
||||
createTripSchema,
|
||||
updateTripSchema,
|
||||
createTripExpenseSchema,
|
||||
updateTripExpenseSchema,
|
||||
} from "@haushaltsApp/shared/schemas/trips";
|
||||
|
||||
type Variables = AuthVariables & TenantVariables;
|
||||
|
||||
export const tripsRoutes = new Hono<{ Variables: Variables }>();
|
||||
|
||||
tripsRoutes.use("/*", authMiddleware, requireAuth, tenantMiddleware, requireHousehold);
|
||||
|
||||
// GET / — list all trips
|
||||
tripsRoutes.get("/", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const data = await getTrips(householdId);
|
||||
return c.json({ trips: data });
|
||||
});
|
||||
|
||||
// POST / — create a trip
|
||||
tripsRoutes.post(
|
||||
"/",
|
||||
zValidator("json", createTripSchema),
|
||||
async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const input = c.req.valid("json");
|
||||
const trip = await createTrip(householdId, input);
|
||||
return c.json({ trip }, 201);
|
||||
},
|
||||
);
|
||||
|
||||
// PATCH /:id — update a trip
|
||||
tripsRoutes.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateTripSchema),
|
||||
async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
const trip = await updateTrip(id, householdId, input);
|
||||
if (!trip) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ trip });
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /:id — delete a trip (fails if it has expenses)
|
||||
tripsRoutes.delete("/:id", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
try {
|
||||
const deleted = await deleteTrip(id, householdId);
|
||||
if (!deleted) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ success: true });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Has expenses") {
|
||||
return c.json({ error: "Cannot delete a trip that has expenses" }, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// POST /:id/complete — mark a trip as completed
|
||||
tripsRoutes.post("/:id/complete", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const trip = await completeTrip(id, householdId);
|
||||
if (!trip) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ trip });
|
||||
});
|
||||
|
||||
// GET /:id/expenses — list expenses for a trip
|
||||
tripsRoutes.get("/:id/expenses", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const expenses = await getTripExpenses(id, householdId);
|
||||
return c.json({ expenses });
|
||||
});
|
||||
|
||||
// POST /:id/expenses — add an expense to a trip
|
||||
tripsRoutes.post(
|
||||
"/:id/expenses",
|
||||
zValidator("json", createTripExpenseSchema),
|
||||
async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
try {
|
||||
const expense = await createTripExpense(id, householdId, input);
|
||||
return c.json({ expense }, 201);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Trip not found") {
|
||||
return c.json({ error: "Not found" }, 404);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// PATCH /:id/expenses/:eid — update a trip expense
|
||||
tripsRoutes.patch(
|
||||
"/:id/expenses/:eid",
|
||||
zValidator("json", updateTripExpenseSchema),
|
||||
async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id, eid } = c.req.param();
|
||||
const input = c.req.valid("json");
|
||||
const expense = await updateTripExpense(eid, id, householdId, input);
|
||||
if (!expense) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ expense });
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /:id/expenses/:eid — delete a trip expense
|
||||
tripsRoutes.delete("/:id/expenses/:eid", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id, eid } = c.req.param();
|
||||
const deleted = await deleteTripExpense(eid, id, householdId);
|
||||
if (!deleted) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /:id/settlement — preview settlement for a trip
|
||||
tripsRoutes.get("/:id/settlement", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const settlement = await getTripSettlementPreview(id, householdId);
|
||||
if (!settlement) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ settlement });
|
||||
});
|
||||
|
||||
// GET /:id/summary — trip summary with spending by category
|
||||
tripsRoutes.get("/:id/summary", async (c) => {
|
||||
const householdId = c.get("householdId") as string;
|
||||
const { id } = c.req.param();
|
||||
const summary = await getTripSummary(id, householdId);
|
||||
if (!summary) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ summary });
|
||||
});
|
||||
62
apps/server/src/services/category.service.ts
Normal file
62
apps/server/src/services/category.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { db, eq, and, sql } from "@haushaltsApp/db";
|
||||
import { categories, transactions } from "@haushaltsApp/db/schema";
|
||||
|
||||
const DEFAULT_CATEGORIES = [
|
||||
{ name: "Lebensmittel", color: "#10b981", type: "expense" as const },
|
||||
{ name: "Wohnen", color: "#6366f1", type: "expense" as const },
|
||||
{ name: "Transport", color: "#f59e0b", type: "expense" as const },
|
||||
{ name: "Gesundheit", color: "#ef4444", type: "expense" as const },
|
||||
{ name: "Freizeit", color: "#8b5cf6", type: "expense" as const },
|
||||
{ name: "Kinder", color: "#ec4899", type: "expense" as const },
|
||||
{ name: "Urlaub", color: "#0ea5e9", type: "expense" as const },
|
||||
{ name: "Sonstiges", color: "#6b7280", type: "expense" as const },
|
||||
{ name: "Gehalt", color: "#10b981", type: "income" as const },
|
||||
{ name: "Sonstiges Einkommen", color: "#6b7280", type: "income" as const },
|
||||
] as const;
|
||||
|
||||
export async function seedDefaultCategories(householdId: string) {
|
||||
const rows = DEFAULT_CATEGORIES.map((cat) => ({
|
||||
householdId,
|
||||
name: cat.name,
|
||||
color: cat.color,
|
||||
type: cat.type,
|
||||
isDefault: true,
|
||||
}));
|
||||
return db.insert(categories).values(rows).returning();
|
||||
}
|
||||
|
||||
export async function getCategoriesByHousehold(householdId: string) {
|
||||
return db.select().from(categories).where(eq(categories.householdId, householdId));
|
||||
}
|
||||
|
||||
export async function createCategory(householdId: string, data: { name: string; icon?: string | null; color?: string | null; type: "income" | "expense" }) {
|
||||
const [cat] = await db.insert(categories).values({
|
||||
householdId,
|
||||
name: data.name,
|
||||
icon: data.icon ?? null,
|
||||
color: data.color ?? null,
|
||||
type: data.type,
|
||||
isDefault: false,
|
||||
}).returning();
|
||||
return cat;
|
||||
}
|
||||
|
||||
export async function updateCategory(householdId: string, categoryId: string, data: { name?: string; icon?: string | null; color?: string | null }) {
|
||||
const [cat] = await db.update(categories)
|
||||
.set({ ...(data.name ? { name: data.name } : {}), icon: data.icon, color: data.color })
|
||||
.where(and(eq(categories.id, categoryId), eq(categories.householdId, householdId)))
|
||||
.returning();
|
||||
return cat ?? null;
|
||||
}
|
||||
|
||||
export async function deleteCategory(householdId: string, categoryId: string): Promise<{ deleted: boolean; usageCount: number }> {
|
||||
// Check if any transactions use this category
|
||||
const usageCount = await db.select({ count: sql<number>`count(*)::int` }).from(transactions)
|
||||
.where(and(eq(transactions.householdId, householdId), eq(transactions.categoryId, categoryId)));
|
||||
const count = usageCount[0]?.count ?? 0;
|
||||
if (count > 0) {
|
||||
return { deleted: false, usageCount: count };
|
||||
}
|
||||
await db.delete(categories).where(and(eq(categories.id, categoryId), eq(categories.householdId, householdId), eq(categories.isDefault, false)));
|
||||
return { deleted: true, usageCount: 0 };
|
||||
}
|
||||
48
apps/server/src/services/children.service.ts
Normal file
48
apps/server/src/services/children.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { db, eq, and } from "@haushaltsApp/db";
|
||||
import { children } from "@haushaltsApp/db/schema";
|
||||
import type { CreateChildInput, UpdateChildInput } from "@haushaltsApp/shared/schemas/children.schema";
|
||||
|
||||
export async function getChildren(householdId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(children)
|
||||
.where(eq(children.householdId, householdId))
|
||||
.orderBy(children.createdAt);
|
||||
}
|
||||
|
||||
export async function getChildById(id: string, householdId: string) {
|
||||
const [child] = await db
|
||||
.select()
|
||||
.from(children)
|
||||
.where(and(eq(children.id, id), eq(children.householdId, householdId)));
|
||||
return child ?? null;
|
||||
}
|
||||
|
||||
export async function createChild(householdId: string, input: CreateChildInput) {
|
||||
const [child] = await db
|
||||
.insert(children)
|
||||
.values({ householdId, name: input.name, color: input.color })
|
||||
.returning();
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function updateChild(id: string, householdId: string, input: UpdateChildInput) {
|
||||
const values: Partial<typeof input> = {};
|
||||
if (input.name !== undefined) values.name = input.name;
|
||||
if (input.color !== undefined) values.color = input.color;
|
||||
|
||||
const [child] = await db
|
||||
.update(children)
|
||||
.set(values)
|
||||
.where(and(eq(children.id, id), eq(children.householdId, householdId)))
|
||||
.returning();
|
||||
return child ?? null;
|
||||
}
|
||||
|
||||
export async function deleteChild(id: string, householdId: string) {
|
||||
const [child] = await db
|
||||
.delete(children)
|
||||
.where(and(eq(children.id, id), eq(children.householdId, householdId)))
|
||||
.returning();
|
||||
return child ?? null;
|
||||
}
|
||||
235
apps/server/src/services/debt.service.ts
Normal file
235
apps/server/src/services/debt.service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { db, eq, and, desc, sql } from "@haushaltsApp/db";
|
||||
import { debts, debtPayments, transactions } from "@haushaltsApp/db/schema";
|
||||
import { user } from "@haushaltsApp/db/schema";
|
||||
import type { CreateDebtInput, CreateDebtPaymentInput } from "@haushaltsApp/shared/schemas/debt.schema";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DebtWithProgress = {
|
||||
id: string;
|
||||
householdId: string;
|
||||
userId: string;
|
||||
creditorUserId: string | null;
|
||||
creditorUserName: string | null;
|
||||
label: string;
|
||||
creditor: string | null;
|
||||
totalAmount: number;
|
||||
paidAmount: number;
|
||||
remainingAmount: number;
|
||||
progressPercent: number;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
closedAt: Date | null;
|
||||
};
|
||||
|
||||
export type DebtPayment = {
|
||||
id: string;
|
||||
debtId: string;
|
||||
amount: number;
|
||||
date: string;
|
||||
note: string | null;
|
||||
linkedTransactionId: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getPaidAmount(debtId: string): Promise<number> {
|
||||
const result = await db
|
||||
.select({ total: sql<string>`coalesce(sum(${debtPayments.amount}), '0')` })
|
||||
.from(debtPayments)
|
||||
.where(eq(debtPayments.debtId, debtId));
|
||||
return parseFloat(result[0]?.total ?? "0");
|
||||
}
|
||||
|
||||
function withProgress(
|
||||
debt: typeof debts.$inferSelect,
|
||||
paidAmount: number,
|
||||
creditorUserName?: string | null,
|
||||
): DebtWithProgress {
|
||||
const total = parseFloat(debt.totalAmount);
|
||||
const remaining = Math.max(0, total - paidAmount);
|
||||
const progressPercent = total > 0 ? Math.min(100, (paidAmount / total) * 100) : 0;
|
||||
return {
|
||||
id: debt.id,
|
||||
householdId: debt.householdId,
|
||||
userId: debt.userId,
|
||||
creditorUserId: debt.creditorUserId,
|
||||
creditorUserName: creditorUserName ?? null,
|
||||
label: debt.label,
|
||||
creditor: debt.creditor,
|
||||
totalAmount: total,
|
||||
paidAmount,
|
||||
remainingAmount: remaining,
|
||||
progressPercent,
|
||||
notes: debt.notes,
|
||||
createdAt: debt.createdAt,
|
||||
closedAt: debt.closedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Service functions ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getDebts(householdId: string, userId: string): Promise<DebtWithProgress[]> {
|
||||
const rows = await db
|
||||
.select({ debt: debts, creditorName: user.name })
|
||||
.from(debts)
|
||||
.leftJoin(user, eq(debts.creditorUserId, user.id))
|
||||
.where(and(eq(debts.householdId, householdId), eq(debts.userId, userId)))
|
||||
.orderBy(desc(debts.createdAt));
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async ({ debt, creditorName }) => {
|
||||
const paid = await getPaidAmount(debt.id);
|
||||
return withProgress(debt, paid, creditorName);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Debts where this user is the creditor (= "Forderungen")
|
||||
export async function getClaims(householdId: string, userId: string): Promise<DebtWithProgress[]> {
|
||||
const rows = await db
|
||||
.select({ debt: debts, debtorName: user.name })
|
||||
.from(debts)
|
||||
.leftJoin(user, eq(debts.userId, user.id))
|
||||
.where(and(eq(debts.householdId, householdId), eq(debts.creditorUserId, userId)))
|
||||
.orderBy(desc(debts.createdAt));
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async ({ debt, debtorName }) => {
|
||||
const paid = await getPaidAmount(debt.id);
|
||||
// creditorUserName = debtor name (for display in claims section)
|
||||
return { ...withProgress(debt, paid), creditorUserName: debtorName };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createDebt(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
input: CreateDebtInput,
|
||||
): Promise<DebtWithProgress> {
|
||||
const [debt] = await db
|
||||
.insert(debts)
|
||||
.values({
|
||||
householdId,
|
||||
userId,
|
||||
creditorUserId: input.creditorUserId ?? null,
|
||||
label: input.label,
|
||||
creditor: input.creditor ?? null,
|
||||
totalAmount: String(input.totalAmount),
|
||||
notes: input.notes ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Resolve creditor name if internal
|
||||
let creditorUserName: string | null = null;
|
||||
if (input.creditorUserId) {
|
||||
const [u] = await db.select({ name: user.name }).from(user).where(eq(user.id, input.creditorUserId));
|
||||
creditorUserName = u?.name ?? null;
|
||||
}
|
||||
|
||||
return withProgress(debt!, 0, creditorUserName);
|
||||
}
|
||||
|
||||
export async function getDebtPayments(debtId: string, householdId: string): Promise<DebtPayment[]> {
|
||||
const [debt] = await db
|
||||
.select({ id: debts.id })
|
||||
.from(debts)
|
||||
.where(and(eq(debts.id, debtId), eq(debts.householdId, householdId)));
|
||||
if (!debt) return [];
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(debtPayments)
|
||||
.where(eq(debtPayments.debtId, debtId))
|
||||
.orderBy(desc(debtPayments.createdAt));
|
||||
|
||||
return rows.map((p) => ({
|
||||
id: p.id,
|
||||
debtId: p.debtId,
|
||||
amount: parseFloat(p.amount),
|
||||
date: p.date,
|
||||
note: p.note,
|
||||
linkedTransactionId: p.linkedTransactionId,
|
||||
createdAt: p.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createDebtPayment(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
input: CreateDebtPaymentInput,
|
||||
): Promise<{ payment: DebtPayment; debt: DebtWithProgress }> {
|
||||
const [debt] = await db
|
||||
.select()
|
||||
.from(debts)
|
||||
.where(
|
||||
and(
|
||||
eq(debts.id, input.debtId),
|
||||
eq(debts.householdId, householdId),
|
||||
eq(debts.userId, userId),
|
||||
),
|
||||
);
|
||||
if (!debt) throw new Error("Debt not found");
|
||||
|
||||
const [tx] = await db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
householdId,
|
||||
userId,
|
||||
scope: "private",
|
||||
type: "expense",
|
||||
amount: String(input.amount),
|
||||
description: `Rate: ${debt.label}`,
|
||||
date: input.date,
|
||||
})
|
||||
.returning({ id: transactions.id });
|
||||
|
||||
const [payment] = await db
|
||||
.insert(debtPayments)
|
||||
.values({
|
||||
debtId: input.debtId,
|
||||
amount: String(input.amount),
|
||||
date: input.date,
|
||||
note: input.note ?? null,
|
||||
linkedTransactionId: tx!.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const paidAmount = await getPaidAmount(input.debtId);
|
||||
let updatedDebt = debt;
|
||||
if (paidAmount >= parseFloat(debt.totalAmount) && !debt.closedAt) {
|
||||
const [closed] = await db
|
||||
.update(debts)
|
||||
.set({ closedAt: new Date() })
|
||||
.where(eq(debts.id, input.debtId))
|
||||
.returning();
|
||||
updatedDebt = closed ?? debt;
|
||||
}
|
||||
|
||||
return {
|
||||
payment: {
|
||||
id: payment!.id,
|
||||
debtId: payment!.debtId,
|
||||
amount: parseFloat(payment!.amount),
|
||||
date: payment!.date,
|
||||
note: payment!.note,
|
||||
linkedTransactionId: payment!.linkedTransactionId,
|
||||
createdAt: payment!.createdAt,
|
||||
},
|
||||
debt: withProgress(updatedDebt, paidAmount),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteDebt(
|
||||
id: string,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const result = await db
|
||||
.delete(debts)
|
||||
.where(and(eq(debts.id, id), eq(debts.householdId, householdId), eq(debts.userId, userId)))
|
||||
.returning({ id: debts.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
566
apps/server/src/services/fixed-costs.service.ts
Normal file
566
apps/server/src/services/fixed-costs.service.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
import { db, eq, and, desc } from "@haushaltsApp/db";
|
||||
import { fixedCosts, monthlyTransfers, transferLineItems, transactions } from "@haushaltsApp/db/schema";
|
||||
import { member, user } from "@haushaltsApp/db/schema";
|
||||
import { sql } from "@haushaltsApp/db";
|
||||
import { getOrCreateHouseholdSettings } from "./household-settings.service";
|
||||
import type {
|
||||
CreateFixedCostInput,
|
||||
UpdateFixedCostInput,
|
||||
CreateTransferLineItemInput,
|
||||
UpdateTransferLineItemInput,
|
||||
CreateMonthlyTransferInput,
|
||||
} from "@haushaltsApp/shared/schemas/fixed-costs.schema";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type FixedCost = {
|
||||
id: string;
|
||||
householdId: string;
|
||||
scope: "household" | "private" | "child";
|
||||
childId: string | null;
|
||||
categoryId: string | null;
|
||||
label: string;
|
||||
amount: number;
|
||||
type: "income" | "expense";
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type TransferLineItem = {
|
||||
id: string;
|
||||
householdId: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type MonthlyTransfer = {
|
||||
id: string;
|
||||
householdId: string;
|
||||
month: string;
|
||||
fromUserId: string;
|
||||
toUserId: string;
|
||||
amount: number;
|
||||
note: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type NettoMonth = {
|
||||
month: string;
|
||||
totalIncome: number;
|
||||
incomeByScope: Array<{ scope: string; label: string; amount: number }>;
|
||||
totalExpenses: number;
|
||||
netto: number;
|
||||
};
|
||||
|
||||
export type SettlementV2 = {
|
||||
month: string;
|
||||
householdExpenses: number;
|
||||
householdIncome: number;
|
||||
householdNet: number;
|
||||
memberCount: number;
|
||||
perMemberShare: number;
|
||||
userSharePercent: number;
|
||||
payerUserId: string;
|
||||
isPayer: boolean;
|
||||
lineItems: Array<{ id: string; label: string; amount: number }>;
|
||||
lineItemsTotal: number;
|
||||
myOwnExpenses: number;
|
||||
transfers: MonthlyTransfer[];
|
||||
alreadyTransferred: number;
|
||||
totalOwed: number;
|
||||
remaining: number;
|
||||
members: Array<{ userId: string; name: string; paid: number; owes: number }>;
|
||||
};
|
||||
|
||||
// ── Fixed Costs ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFixedCosts(householdId: string): Promise<FixedCost[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fixedCosts)
|
||||
.where(eq(fixedCosts.householdId, householdId))
|
||||
.orderBy(desc(fixedCosts.createdAt));
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
householdId: r.householdId,
|
||||
scope: r.scope,
|
||||
childId: r.childId,
|
||||
categoryId: r.categoryId,
|
||||
label: r.label,
|
||||
amount: parseFloat(r.amount),
|
||||
type: r.type,
|
||||
isActive: r.isActive,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createFixedCost(
|
||||
householdId: string,
|
||||
input: CreateFixedCostInput,
|
||||
): Promise<FixedCost> {
|
||||
const [row] = await db
|
||||
.insert(fixedCosts)
|
||||
.values({
|
||||
householdId,
|
||||
scope: input.scope,
|
||||
childId: input.childId ?? null,
|
||||
categoryId: input.categoryId ?? null,
|
||||
label: input.label,
|
||||
amount: String(input.amount),
|
||||
type: input.type,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: row!.id,
|
||||
householdId: row!.householdId,
|
||||
scope: row!.scope,
|
||||
childId: row!.childId,
|
||||
categoryId: row!.categoryId,
|
||||
label: row!.label,
|
||||
amount: parseFloat(row!.amount),
|
||||
type: row!.type,
|
||||
isActive: row!.isActive,
|
||||
createdAt: row!.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateFixedCost(
|
||||
id: string,
|
||||
householdId: string,
|
||||
input: UpdateFixedCostInput,
|
||||
): Promise<FixedCost | null> {
|
||||
const values: Partial<typeof fixedCosts.$inferInsert> = {};
|
||||
if (input.label !== undefined) values.label = input.label;
|
||||
if (input.amount !== undefined) values.amount = String(input.amount);
|
||||
if (input.categoryId !== undefined) values.categoryId = input.categoryId;
|
||||
if (input.isActive !== undefined) values.isActive = input.isActive;
|
||||
|
||||
const [row] = await db
|
||||
.update(fixedCosts)
|
||||
.set(values)
|
||||
.where(and(eq(fixedCosts.id, id), eq(fixedCosts.householdId, householdId)))
|
||||
.returning();
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
householdId: row.householdId,
|
||||
scope: row.scope,
|
||||
childId: row.childId,
|
||||
categoryId: row.categoryId,
|
||||
label: row.label,
|
||||
amount: parseFloat(row.amount),
|
||||
type: row.type,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteFixedCost(id: string, householdId: string): Promise<boolean> {
|
||||
// Soft delete — pause, don't destroy history
|
||||
const [row] = await db
|
||||
.update(fixedCosts)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(fixedCosts.id, id), eq(fixedCosts.householdId, householdId)))
|
||||
.returning({ id: fixedCosts.id });
|
||||
return !!row;
|
||||
}
|
||||
|
||||
// ── Transfer Line Items ───────────────────────────────────────────────────────
|
||||
|
||||
export async function getTransferLineItems(householdId: string): Promise<TransferLineItem[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(transferLineItems)
|
||||
.where(and(eq(transferLineItems.householdId, householdId), eq(transferLineItems.isActive, true)))
|
||||
.orderBy(desc(transferLineItems.createdAt));
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
householdId: r.householdId,
|
||||
label: r.label,
|
||||
amount: parseFloat(r.amount),
|
||||
isActive: r.isActive,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createTransferLineItem(
|
||||
householdId: string,
|
||||
input: CreateTransferLineItemInput,
|
||||
): Promise<TransferLineItem> {
|
||||
const [row] = await db
|
||||
.insert(transferLineItems)
|
||||
.values({ householdId, label: input.label, amount: String(input.amount) })
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: row!.id,
|
||||
householdId: row!.householdId,
|
||||
label: row!.label,
|
||||
amount: parseFloat(row!.amount),
|
||||
isActive: row!.isActive,
|
||||
createdAt: row!.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateTransferLineItem(
|
||||
id: string,
|
||||
householdId: string,
|
||||
input: UpdateTransferLineItemInput,
|
||||
): Promise<TransferLineItem | null> {
|
||||
const values: Partial<typeof transferLineItems.$inferInsert> = {};
|
||||
if (input.label !== undefined) values.label = input.label;
|
||||
if (input.amount !== undefined) values.amount = String(input.amount);
|
||||
if (input.isActive !== undefined) values.isActive = input.isActive;
|
||||
|
||||
const [row] = await db
|
||||
.update(transferLineItems)
|
||||
.set(values)
|
||||
.where(and(eq(transferLineItems.id, id), eq(transferLineItems.householdId, householdId)))
|
||||
.returning();
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
householdId: row.householdId,
|
||||
label: row.label,
|
||||
amount: parseFloat(row.amount),
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteTransferLineItem(id: string, householdId: string): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.update(transferLineItems)
|
||||
.set({ isActive: false })
|
||||
.where(and(eq(transferLineItems.id, id), eq(transferLineItems.householdId, householdId)))
|
||||
.returning({ id: transferLineItems.id });
|
||||
return !!row;
|
||||
}
|
||||
|
||||
// ── Monthly Transfers ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getMonthlyTransfers(
|
||||
householdId: string,
|
||||
month: string,
|
||||
): Promise<MonthlyTransfer[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(monthlyTransfers)
|
||||
.where(
|
||||
and(eq(monthlyTransfers.householdId, householdId), eq(monthlyTransfers.month, month)),
|
||||
)
|
||||
.orderBy(desc(monthlyTransfers.createdAt));
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
householdId: r.householdId,
|
||||
month: r.month,
|
||||
fromUserId: r.fromUserId,
|
||||
toUserId: r.toUserId,
|
||||
amount: parseFloat(r.amount),
|
||||
note: r.note,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createMonthlyTransfer(
|
||||
householdId: string,
|
||||
fromUserId: string,
|
||||
input: CreateMonthlyTransferInput,
|
||||
): Promise<MonthlyTransfer> {
|
||||
const [row] = await db
|
||||
.insert(monthlyTransfers)
|
||||
.values({
|
||||
householdId,
|
||||
month: input.month,
|
||||
fromUserId,
|
||||
toUserId: input.toUserId,
|
||||
amount: String(input.amount),
|
||||
note: input.note ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: row!.id,
|
||||
householdId: row!.householdId,
|
||||
month: row!.month,
|
||||
fromUserId: row!.fromUserId,
|
||||
toUserId: row!.toUserId,
|
||||
amount: parseFloat(row!.amount),
|
||||
note: row!.note,
|
||||
createdAt: row!.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Settlement V2 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSettlementV2(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
month: string,
|
||||
): Promise<SettlementV2> {
|
||||
const [y, m] = month.split("-").map(Number);
|
||||
const from = `${month}-01`;
|
||||
const lastDay = new Date(y!, m!, 0).getDate();
|
||||
const to = `${month}-${String(lastDay).padStart(2, "0")}`;
|
||||
|
||||
// Members (with role to determine owner vs partner)
|
||||
const members = await db
|
||||
.select({ userId: member.userId, name: user.name, role: member.role })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, householdId));
|
||||
const memberCount = members.length;
|
||||
|
||||
const settings = await getOrCreateHouseholdSettings(householdId);
|
||||
const ownerMember = members.find((m) => m.role === "owner");
|
||||
// payerUserId = who pays all expenses upfront (defaults to owner if not set)
|
||||
const payerUserId = settings.payerUserId ?? ownerMember?.userId ?? userId;
|
||||
const isPayer = userId === payerUserId;
|
||||
// userSharePercent in settings = the PAYER's share (e.g. René pays 75%)
|
||||
const baseSharePercent = Number(settings.userSharePercent ?? 50);
|
||||
const userSharePercent = isPayer
|
||||
? baseSharePercent // payer's own share
|
||||
: 100 - baseSharePercent; // debtor owes the remainder
|
||||
|
||||
// Household expenses by user
|
||||
const expenseRows = await db
|
||||
.select({
|
||||
userId: transactions.userId,
|
||||
total: sql<string>`coalesce(sum(${transactions.amount}::numeric), '0')`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.scope, "household"),
|
||||
eq(transactions.type, "expense"),
|
||||
sql`${transactions.date} >= ${from}`,
|
||||
sql`${transactions.date} <= ${to}`,
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.userId);
|
||||
|
||||
const paidByUser: Record<string, number> = {};
|
||||
let householdExpenses = 0;
|
||||
for (const row of expenseRows) {
|
||||
const amount = parseFloat(row.total);
|
||||
paidByUser[row.userId] = amount;
|
||||
householdExpenses += amount;
|
||||
}
|
||||
|
||||
// Household income
|
||||
const [incomeRow] = await db
|
||||
.select({ total: sql<string>`coalesce(sum(${transactions.amount}::numeric), '0')` })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.scope, "household"),
|
||||
eq(transactions.type, "income"),
|
||||
sql`${transactions.date} >= ${from}`,
|
||||
sql`${transactions.date} <= ${to}`,
|
||||
),
|
||||
);
|
||||
const householdIncome = parseFloat(incomeRow?.total ?? "0");
|
||||
|
||||
const householdNet = householdExpenses - householdIncome;
|
||||
const perMemberShare = householdNet * (userSharePercent / 100);
|
||||
|
||||
// Line items
|
||||
const lineItemRows = await db
|
||||
.select()
|
||||
.from(transferLineItems)
|
||||
.where(and(eq(transferLineItems.householdId, householdId), eq(transferLineItems.isActive, true)));
|
||||
|
||||
const lineItems = lineItemRows.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
amount: parseFloat(r.amount),
|
||||
}));
|
||||
const lineItemsTotal = lineItems.reduce((sum, li) => sum + li.amount, 0);
|
||||
|
||||
// Already transferred this month (by me)
|
||||
const transferRows = await getMonthlyTransfers(householdId, month);
|
||||
const myTransfers = transferRows.filter((t) => t.fromUserId === userId);
|
||||
const alreadyTransferred = myTransfers.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
// Only manual (non-fixed) expenses by the debtor count as "already paid by them"
|
||||
// Fixed costs are always attributed to the payer regardless of which userId booked them
|
||||
let myOwnExpenses = 0;
|
||||
if (!isPayer) {
|
||||
const [manualRow] = await db
|
||||
.select({ total: sql<string>`coalesce(sum(${transactions.amount}::numeric), '0')` })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.scope, "household"),
|
||||
eq(transactions.type, "expense"),
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.isFixed, false),
|
||||
sql`${transactions.date} >= ${from}`,
|
||||
sql`${transactions.date} <= ${to}`,
|
||||
),
|
||||
);
|
||||
myOwnExpenses = parseFloat(manualRow?.total ?? "0");
|
||||
}
|
||||
const totalOwed = perMemberShare + lineItemsTotal - myOwnExpenses;
|
||||
const remaining = totalOwed - alreadyTransferred;
|
||||
|
||||
const membersResult = members.map((mem) => {
|
||||
const paid = paidByUser[mem.userId] ?? 0;
|
||||
const memIsPayer = mem.userId === payerUserId;
|
||||
const memSharePercent = memIsPayer
|
||||
? baseSharePercent
|
||||
: 100 - baseSharePercent;
|
||||
const share = householdNet * (memSharePercent / 100);
|
||||
return { userId: mem.userId, name: mem.name, paid, owes: share - paid };
|
||||
});
|
||||
|
||||
return {
|
||||
month,
|
||||
householdExpenses,
|
||||
householdIncome,
|
||||
householdNet,
|
||||
memberCount,
|
||||
perMemberShare,
|
||||
userSharePercent,
|
||||
payerUserId,
|
||||
isPayer,
|
||||
lineItems,
|
||||
lineItemsTotal,
|
||||
myOwnExpenses,
|
||||
transfers: transferRows,
|
||||
alreadyTransferred,
|
||||
totalOwed,
|
||||
remaining,
|
||||
members: membersResult,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Netto Month ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getNettoMonth(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
month: string,
|
||||
): Promise<NettoMonth | null> {
|
||||
const [y, m] = month.split("-").map(Number);
|
||||
const from = `${month}-01`;
|
||||
const lastDay = new Date(y!, m!, 0).getDate();
|
||||
const to = `${month}-${String(lastDay).padStart(2, "0")}`;
|
||||
|
||||
// Check if month has any transactions at all — return null for empty months
|
||||
const [countRow] = await db
|
||||
.select({ count: sql<string>`count(*)` })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.householdId, householdId),
|
||||
sql`${transactions.date} >= ${from}`,
|
||||
sql`${transactions.date} <= ${to}`,
|
||||
),
|
||||
);
|
||||
const txCount = parseInt(countRow?.count ?? "0", 10);
|
||||
if (txCount === 0) return null;
|
||||
|
||||
const settings = await getOrCreateHouseholdSettings(householdId);
|
||||
const userShare = settings.userSharePercent / 100;
|
||||
const childShare = settings.splitChildCosts ? userShare : 0;
|
||||
|
||||
// Income: all scopes (household income is the user's own income bookings)
|
||||
const incomeRows = await db
|
||||
.select({
|
||||
scope: transactions.scope,
|
||||
total: sql<string>`coalesce(sum(${transactions.amount}::numeric), '0')`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.type, "income"),
|
||||
sql`${transactions.date} >= ${from}`,
|
||||
sql`${transactions.date} <= ${to}`,
|
||||
),
|
||||
)
|
||||
.groupBy(transactions.scope);
|
||||
|
||||
const incomeByScope = incomeRows.map((r) => ({
|
||||
scope: r.scope,
|
||||
label: r.scope === "household" ? "Haushalt" : r.scope === "private" ? "Privat" : "Kinder",
|
||||
amount: parseFloat(r.total),
|
||||
}));
|
||||
const totalIncome = incomeByScope.reduce((sum, s) => sum + s.amount, 0);
|
||||
|
||||
// Household expenses × userShare
|
||||
const [hhExpRow] = await db
|
||||
.select({ total: sql<string>`coalesce(sum(${transactions.amount}::numeric), '0')` })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.scope, "household"),
|
||||
eq(transactions.type, "expense"),
|
||||
sql`${transactions.date} >= ${from}`,
|
||||
sql`${transactions.date} <= ${to}`,
|
||||
),
|
||||
);
|
||||
const householdExpensesShare = parseFloat(hhExpRow?.total ?? "0") * userShare;
|
||||
|
||||
// Private expenses (only mine)
|
||||
const [privExpRow] = await db
|
||||
.select({ total: sql<string>`coalesce(sum(${transactions.amount}::numeric), '0')` })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.userId, userId),
|
||||
eq(transactions.scope, "private"),
|
||||
eq(transactions.type, "expense"),
|
||||
sql`${transactions.date} >= ${from}`,
|
||||
sql`${transactions.date} <= ${to}`,
|
||||
),
|
||||
);
|
||||
const privateExpenses = parseFloat(privExpRow?.total ?? "0");
|
||||
|
||||
// Child expenses × childShare
|
||||
const [childExpRow] = await db
|
||||
.select({ total: sql<string>`coalesce(sum(${transactions.amount}::numeric), '0')` })
|
||||
.from(transactions)
|
||||
.where(
|
||||
and(
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.scope, "child"),
|
||||
eq(transactions.type, "expense"),
|
||||
sql`${transactions.date} >= ${from}`,
|
||||
sql`${transactions.date} <= ${to}`,
|
||||
),
|
||||
);
|
||||
const childExpensesShare = parseFloat(childExpRow?.total ?? "0") * childShare;
|
||||
|
||||
// Fixed transfer line items (only included when month has transactions)
|
||||
const lineItemRows = await db
|
||||
.select()
|
||||
.from(transferLineItems)
|
||||
.where(and(eq(transferLineItems.householdId, householdId), eq(transferLineItems.isActive, true)));
|
||||
const lineItemsTotal = lineItemRows.reduce((sum, r) => sum + parseFloat(r.amount), 0);
|
||||
|
||||
const totalExpenses = householdExpensesShare + privateExpenses + childExpensesShare + lineItemsTotal;
|
||||
|
||||
return {
|
||||
month,
|
||||
totalIncome,
|
||||
incomeByScope,
|
||||
totalExpenses,
|
||||
netto: totalIncome - totalExpenses,
|
||||
};
|
||||
}
|
||||
107
apps/server/src/services/household-settings.service.ts
Normal file
107
apps/server/src/services/household-settings.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { db, eq } from "@haushaltsApp/db";
|
||||
import { householdSettings } from "@haushaltsApp/db/schema";
|
||||
import type { UpdateHouseholdSettingsInput } from "@haushaltsApp/shared/schemas/household-settings.schema";
|
||||
|
||||
export type HouseholdSettings = {
|
||||
id: string;
|
||||
householdId: string;
|
||||
ownerName: string;
|
||||
partnerName: string;
|
||||
userSharePercent: number;
|
||||
monthlyBudget: number;
|
||||
currency: string;
|
||||
splitChildCosts: boolean;
|
||||
payerUserId: string | null;
|
||||
onboardingComplete: boolean;
|
||||
language: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function mapRow(row: typeof householdSettings.$inferSelect): HouseholdSettings {
|
||||
return {
|
||||
id: row.id,
|
||||
householdId: row.householdId,
|
||||
ownerName: row.ownerName,
|
||||
partnerName: row.partnerName,
|
||||
userSharePercent: parseFloat(row.userSharePercent),
|
||||
monthlyBudget: parseFloat(row.monthlyBudget),
|
||||
currency: row.currency,
|
||||
splitChildCosts: row.splitChildCosts,
|
||||
payerUserId: row.payerUserId,
|
||||
onboardingComplete: row.onboardingComplete,
|
||||
language: row.language,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULTS: Omit<HouseholdSettings, "id" | "householdId" | "createdAt" | "updatedAt"> = {
|
||||
ownerName: "Ich",
|
||||
partnerName: "Partner",
|
||||
userSharePercent: 50,
|
||||
monthlyBudget: 400,
|
||||
currency: "EUR",
|
||||
splitChildCosts: true,
|
||||
payerUserId: null,
|
||||
onboardingComplete: false,
|
||||
language: "auto",
|
||||
};
|
||||
|
||||
export async function getOrCreateHouseholdSettings(householdId: string): Promise<HouseholdSettings> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(householdSettings)
|
||||
.where(eq(householdSettings.householdId, householdId))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) return mapRow(existing[0]);
|
||||
|
||||
// Auto-create with defaults (idempotent — safe to call multiple times)
|
||||
const [created] = await db
|
||||
.insert(householdSettings)
|
||||
.values({ householdId })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (created) return mapRow(created);
|
||||
|
||||
// Race condition: another request created it first
|
||||
const retry = await db
|
||||
.select()
|
||||
.from(householdSettings)
|
||||
.where(eq(householdSettings.householdId, householdId))
|
||||
.limit(1);
|
||||
|
||||
return mapRow(retry[0]!);
|
||||
}
|
||||
|
||||
export async function updateHouseholdSettings(
|
||||
householdId: string,
|
||||
input: UpdateHouseholdSettingsInput,
|
||||
): Promise<HouseholdSettings> {
|
||||
const values: Partial<typeof householdSettings.$inferInsert> = {};
|
||||
if (input.ownerName !== undefined) values.ownerName = input.ownerName;
|
||||
if (input.partnerName !== undefined) values.partnerName = input.partnerName;
|
||||
if (input.userSharePercent !== undefined) values.userSharePercent = String(input.userSharePercent);
|
||||
if (input.monthlyBudget !== undefined) values.monthlyBudget = String(input.monthlyBudget);
|
||||
if (input.currency !== undefined) values.currency = input.currency;
|
||||
if (input.splitChildCosts !== undefined) values.splitChildCosts = input.splitChildCosts;
|
||||
if (input.payerUserId !== undefined) values.payerUserId = input.payerUserId;
|
||||
if (input.onboardingComplete !== undefined) values.onboardingComplete = input.onboardingComplete;
|
||||
if (input.language !== undefined) values.language = input.language;
|
||||
|
||||
const [row] = await db
|
||||
.update(householdSettings)
|
||||
.set(values)
|
||||
.where(eq(householdSettings.householdId, householdId))
|
||||
.returning();
|
||||
|
||||
if (row) return mapRow(row);
|
||||
|
||||
// Settings don't exist yet — create then update
|
||||
await getOrCreateHouseholdSettings(householdId);
|
||||
return updateHouseholdSettings(householdId, input);
|
||||
}
|
||||
|
||||
export { DEFAULTS as HOUSEHOLD_SETTINGS_DEFAULTS };
|
||||
23
apps/server/src/services/household.service.ts
Normal file
23
apps/server/src/services/household.service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { db, eq } from "@haushaltsApp/db";
|
||||
import { households } from "@haushaltsApp/db/schema";
|
||||
import type { CreateHouseholdInput } from "@haushaltsApp/shared/types";
|
||||
|
||||
export async function createHousehold(ownerId: string, input: CreateHouseholdInput) {
|
||||
const [household] = await db.insert(households).values({
|
||||
...input,
|
||||
ownerId,
|
||||
}).returning();
|
||||
return household;
|
||||
}
|
||||
|
||||
export async function getHouseholdById(id: string) {
|
||||
const [household] = await db
|
||||
.select()
|
||||
.from(households)
|
||||
.where(eq(households.id, id));
|
||||
return household ?? null;
|
||||
}
|
||||
|
||||
export async function getHouseholdsByOwner(ownerId: string) {
|
||||
return db.select().from(households).where(eq(households.ownerId, ownerId));
|
||||
}
|
||||
155
apps/server/src/services/month-status.service.ts
Normal file
155
apps/server/src/services/month-status.service.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { db, eq, and } from "@haushaltsApp/db";
|
||||
import { monthStatus, monthlyTransfers } from "@haushaltsApp/db/schema";
|
||||
|
||||
export type MonthStatus = {
|
||||
id: string;
|
||||
householdId: string;
|
||||
month: string;
|
||||
status: "open" | "closed";
|
||||
closedAt: Date | null;
|
||||
closedBy: string | null;
|
||||
finalAmount: number | null;
|
||||
notes: string | null;
|
||||
finalTransferId: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
function mapRow(row: typeof monthStatus.$inferSelect): MonthStatus {
|
||||
return {
|
||||
id: row.id,
|
||||
householdId: row.householdId,
|
||||
month: row.month,
|
||||
status: row.status as "open" | "closed",
|
||||
closedAt: row.closedAt,
|
||||
closedBy: row.closedBy,
|
||||
finalAmount: row.finalAmount !== null ? parseFloat(row.finalAmount) : null,
|
||||
notes: row.notes,
|
||||
finalTransferId: row.finalTransferId,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMonthStatus(householdId: string, month: string): Promise<MonthStatus> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(monthStatus)
|
||||
.where(and(eq(monthStatus.householdId, householdId), eq(monthStatus.month, month)))
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) return mapRow(existing[0]);
|
||||
|
||||
// Return a virtual "open" status — no DB row needed for open months
|
||||
return {
|
||||
id: "",
|
||||
householdId,
|
||||
month,
|
||||
status: "open",
|
||||
closedAt: null,
|
||||
closedBy: null,
|
||||
finalAmount: null,
|
||||
notes: null,
|
||||
finalTransferId: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export type CloseMonthInput = {
|
||||
finalAmount: number;
|
||||
notes?: string;
|
||||
toUserId: string; // who receives the transfer
|
||||
};
|
||||
|
||||
export async function closeMonth(
|
||||
householdId: string,
|
||||
month: string,
|
||||
userId: string,
|
||||
input: CloseMonthInput,
|
||||
): Promise<MonthStatus> {
|
||||
// Guard: only current or past months
|
||||
const now = new Date();
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
if (month > currentMonth) {
|
||||
throw new Error("Zukünftige Monate können nicht abgeschlossen werden.");
|
||||
}
|
||||
|
||||
// Guard: must be open
|
||||
const current = await getMonthStatus(householdId, month);
|
||||
if (current.status === "closed") {
|
||||
throw new Error("Dieser Monat ist bereits abgeschlossen.");
|
||||
}
|
||||
|
||||
// Book the final transfer if amount > 0
|
||||
let finalTransferId: string | null = null;
|
||||
if (input.finalAmount > 0) {
|
||||
const [transfer] = await db
|
||||
.insert(monthlyTransfers)
|
||||
.values({
|
||||
householdId,
|
||||
month,
|
||||
fromUserId: userId,
|
||||
toUserId: input.toUserId,
|
||||
amount: String(input.finalAmount),
|
||||
note: input.notes ?? `Monatsabschluss ${month}`,
|
||||
})
|
||||
.returning({ id: monthlyTransfers.id });
|
||||
finalTransferId = transfer?.id ?? null;
|
||||
}
|
||||
|
||||
// Upsert month_status row (unique on householdId+month)
|
||||
const closeValues = {
|
||||
status: "closed" as const,
|
||||
closedAt: new Date(),
|
||||
closedBy: userId,
|
||||
finalAmount: String(input.finalAmount),
|
||||
notes: input.notes ?? null,
|
||||
finalTransferId,
|
||||
};
|
||||
const [row] = await db
|
||||
.insert(monthStatus)
|
||||
.values({ householdId, month, ...closeValues })
|
||||
.onConflictDoUpdate({
|
||||
target: [monthStatus.householdId, monthStatus.month],
|
||||
set: closeValues,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapRow(row!);
|
||||
}
|
||||
|
||||
export async function reopenMonth(
|
||||
householdId: string,
|
||||
month: string,
|
||||
): Promise<MonthStatus> {
|
||||
// Find existing row
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(monthStatus)
|
||||
.where(and(eq(monthStatus.householdId, householdId), eq(monthStatus.month, month)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing[0] || existing[0].status === "open") {
|
||||
return getMonthStatus(householdId, month);
|
||||
}
|
||||
|
||||
// Delete the final transfer that was booked on close
|
||||
if (existing[0].finalTransferId) {
|
||||
await db
|
||||
.delete(monthlyTransfers)
|
||||
.where(eq(monthlyTransfers.id, existing[0].finalTransferId));
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.update(monthStatus)
|
||||
.set({
|
||||
status: "open",
|
||||
closedAt: null,
|
||||
closedBy: null,
|
||||
finalAmount: null,
|
||||
notes: null,
|
||||
finalTransferId: null,
|
||||
})
|
||||
.where(and(eq(monthStatus.householdId, householdId), eq(monthStatus.month, month)))
|
||||
.returning();
|
||||
|
||||
return mapRow(row!);
|
||||
}
|
||||
36
apps/server/src/services/shopping-list.service.ts
Normal file
36
apps/server/src/services/shopping-list.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { db, eq } from "@haushaltsApp/db";
|
||||
import { shoppingListItems, shoppingLists } from "@haushaltsApp/db/schema";
|
||||
import type { CreateShoppingListInput, CreateShoppingListItemInput } from "@haushaltsApp/shared/types";
|
||||
|
||||
export async function getShoppingListsByHousehold(householdId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(shoppingLists)
|
||||
.where(eq(shoppingLists.householdId, householdId));
|
||||
}
|
||||
|
||||
export async function createShoppingList(input: CreateShoppingListInput) {
|
||||
const [list] = await db.insert(shoppingLists).values(input).returning();
|
||||
return list;
|
||||
}
|
||||
|
||||
export async function addItemToList(userId: string, input: CreateShoppingListItemInput) {
|
||||
const [item] = await db
|
||||
.insert(shoppingListItems)
|
||||
.values({ ...input, addedByUserId: userId })
|
||||
.returning();
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function toggleItemChecked(id: string, userId: string, isChecked: boolean) {
|
||||
const [item] = await db
|
||||
.update(shoppingListItems)
|
||||
.set({
|
||||
isChecked,
|
||||
checkedByUserId: isChecked ? userId : null,
|
||||
checkedAt: isChecked ? new Date() : null,
|
||||
})
|
||||
.where(eq(shoppingListItems.id, id))
|
||||
.returning();
|
||||
return item;
|
||||
}
|
||||
79
apps/server/src/services/shopping.service.ts
Normal file
79
apps/server/src/services/shopping.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { db, eq, and, asc, isNotNull } from "@haushaltsApp/db";
|
||||
import { shoppingItems } from "@haushaltsApp/db/schema";
|
||||
import type { ShoppingItem } from "@haushaltsApp/shared/schemas/shopping.schema";
|
||||
|
||||
export async function getShoppingItems(householdId: string): Promise<ShoppingItem[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(shoppingItems)
|
||||
.where(eq(shoppingItems.householdId, householdId))
|
||||
.orderBy(asc(shoppingItems.sortOrder), asc(shoppingItems.createdAt));
|
||||
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
export async function addShoppingItem(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
label: string,
|
||||
quantity?: string,
|
||||
): Promise<ShoppingItem> {
|
||||
const [row] = await db
|
||||
.insert(shoppingItems)
|
||||
.values({ householdId, label, quantity: quantity ?? null, addedBy: userId })
|
||||
.returning();
|
||||
return mapRow(row!);
|
||||
}
|
||||
|
||||
export async function checkShoppingItem(
|
||||
id: string,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<ShoppingItem | null> {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const [row] = await db
|
||||
.update(shoppingItems)
|
||||
.set({ checkedBy: userId, checkedAt })
|
||||
.where(and(eq(shoppingItems.id, id), eq(shoppingItems.householdId, householdId)))
|
||||
.returning();
|
||||
return row ? mapRow(row) : null;
|
||||
}
|
||||
|
||||
export async function uncheckShoppingItem(
|
||||
id: string,
|
||||
householdId: string,
|
||||
): Promise<ShoppingItem | null> {
|
||||
const [row] = await db
|
||||
.update(shoppingItems)
|
||||
.set({ checkedBy: null, checkedAt: null })
|
||||
.where(and(eq(shoppingItems.id, id), eq(shoppingItems.householdId, householdId)))
|
||||
.returning();
|
||||
return row ? mapRow(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteShoppingItem(id: string, householdId: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.delete(shoppingItems)
|
||||
.where(and(eq(shoppingItems.id, id), eq(shoppingItems.householdId, householdId)));
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function clearCheckedItems(householdId: string): Promise<void> {
|
||||
await db
|
||||
.delete(shoppingItems)
|
||||
.where(and(eq(shoppingItems.householdId, householdId), isNotNull(shoppingItems.checkedBy)));
|
||||
}
|
||||
|
||||
function mapRow(row: typeof shoppingItems.$inferSelect): ShoppingItem {
|
||||
return {
|
||||
id: row.id,
|
||||
householdId: row.householdId,
|
||||
label: row.label,
|
||||
quantity: row.quantity,
|
||||
addedBy: row.addedBy,
|
||||
checkedBy: row.checkedBy,
|
||||
checkedAt: row.checkedAt,
|
||||
sortOrder: row.sortOrder,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
359
apps/server/src/services/transaction.service.ts
Normal file
359
apps/server/src/services/transaction.service.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { db, eq, and, or, desc, gte, lte, sql } from "@haushaltsApp/db";
|
||||
import { categories, fixedCosts, households, transactions } from "@haushaltsApp/db/schema";
|
||||
import type {
|
||||
CreateTransactionInput,
|
||||
TransactionFilters,
|
||||
UpdateTransactionInput,
|
||||
} from "@haushaltsApp/shared/schemas/transaction";
|
||||
|
||||
export async function getTransactions(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
filters: TransactionFilters = { limit: 50, offset: 0 },
|
||||
) {
|
||||
const conditions = [eq(transactions.householdId, householdId)];
|
||||
|
||||
if (filters.categoryId) {
|
||||
conditions.push(eq(transactions.categoryId, filters.categoryId));
|
||||
}
|
||||
if (filters.type) {
|
||||
conditions.push(eq(transactions.type, filters.type));
|
||||
}
|
||||
if (filters.from) {
|
||||
conditions.push(gte(transactions.date, filters.from.split("T")[0] ?? filters.from));
|
||||
}
|
||||
if (filters.to) {
|
||||
conditions.push(lte(transactions.date, filters.to.split("T")[0] ?? filters.to));
|
||||
}
|
||||
if (filters.childId) {
|
||||
conditions.push(eq(transactions.childId, filters.childId));
|
||||
}
|
||||
|
||||
// scope filter — private transactions only visible to their creator
|
||||
if (filters.scope === "private") {
|
||||
conditions.push(eq(transactions.scope, "private"));
|
||||
conditions.push(eq(transactions.userId, userId));
|
||||
} else if (filters.scope) {
|
||||
conditions.push(eq(transactions.scope, filters.scope));
|
||||
} else {
|
||||
// default: exclude private transactions from other users
|
||||
conditions.push(
|
||||
or(
|
||||
eq(transactions.scope, "household"),
|
||||
eq(transactions.scope, "child"),
|
||||
and(eq(transactions.scope, "private"), eq(transactions.userId, userId)),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
return db
|
||||
.select({
|
||||
id: transactions.id,
|
||||
householdId: transactions.householdId,
|
||||
userId: transactions.userId,
|
||||
categoryId: transactions.categoryId,
|
||||
childId: transactions.childId,
|
||||
scope: transactions.scope,
|
||||
amount: transactions.amount,
|
||||
currency: transactions.currency,
|
||||
type: transactions.type,
|
||||
isFixed: transactions.isFixed,
|
||||
isCarryOver: transactions.isCarryOver,
|
||||
description: transactions.description,
|
||||
merchant: transactions.merchant,
|
||||
date: transactions.date,
|
||||
receiptImageUrl: transactions.receiptImageUrl,
|
||||
createdAt: transactions.createdAt,
|
||||
updatedAt: transactions.updatedAt,
|
||||
categoryName: categories.name,
|
||||
categoryIcon: categories.icon,
|
||||
categoryColor: categories.color,
|
||||
})
|
||||
.from(transactions)
|
||||
.leftJoin(categories, eq(transactions.categoryId, categories.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(transactions.date))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
}
|
||||
|
||||
export async function getTransactionById(id: string, householdId: string) {
|
||||
const [transaction] = await db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(and(eq(transactions.id, id), eq(transactions.householdId, householdId)));
|
||||
return transaction ?? null;
|
||||
}
|
||||
|
||||
export async function createTransaction(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
input: CreateTransactionInput,
|
||||
) {
|
||||
const household = await db.query.households.findFirst({
|
||||
where: eq(households.id, householdId),
|
||||
});
|
||||
if (!household) {
|
||||
throw new Error("Household not found — run onboarding first");
|
||||
}
|
||||
|
||||
const [transaction] = await db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
householdId,
|
||||
userId,
|
||||
categoryId: input.categoryId ?? null,
|
||||
childId: input.childId ?? null,
|
||||
scope: input.scope ?? "household",
|
||||
amount: String(input.amount),
|
||||
currency: "EUR",
|
||||
type: input.type,
|
||||
isFixed: input.isFixed ?? false,
|
||||
description: input.description ?? null,
|
||||
merchant: input.merchant ?? null,
|
||||
date: input.date.split("T")[0] ?? input.date,
|
||||
})
|
||||
.returning();
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export async function updateTransaction(
|
||||
id: string,
|
||||
householdId: string,
|
||||
input: UpdateTransactionInput,
|
||||
) {
|
||||
const values: Record<string, unknown> = {};
|
||||
if (input.amount !== undefined) values.amount = String(input.amount);
|
||||
if (input.type !== undefined) values.type = input.type;
|
||||
if (input.scope !== undefined) values.scope = input.scope;
|
||||
if (input.categoryId !== undefined) values.categoryId = input.categoryId;
|
||||
if (input.childId !== undefined) values.childId = input.childId;
|
||||
if (input.isFixed !== undefined) values.isFixed = input.isFixed;
|
||||
if (input.description !== undefined) values.description = input.description;
|
||||
if (input.merchant !== undefined) values.merchant = input.merchant;
|
||||
if (input.date !== undefined) values.date = input.date.split("T")[0] ?? input.date;
|
||||
|
||||
const [transaction] = await db
|
||||
.update(transactions)
|
||||
.set(values)
|
||||
.where(and(eq(transactions.id, id), eq(transactions.householdId, householdId)))
|
||||
.returning();
|
||||
return transaction ?? null;
|
||||
}
|
||||
|
||||
export async function deleteTransaction(id: string, householdId: string) {
|
||||
const [transaction] = await db
|
||||
.delete(transactions)
|
||||
.where(and(eq(transactions.id, id), eq(transactions.householdId, householdId)))
|
||||
.returning();
|
||||
return transaction ?? null;
|
||||
}
|
||||
|
||||
// 11a — activate fixed transactions for a month (reads from fixed_costs templates)
|
||||
export async function activateFixedTransactions(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
month: string, // "YYYY-MM"
|
||||
scope: "household" | "private" | "child",
|
||||
childId?: string,
|
||||
) {
|
||||
// Guard: never auto-fill past months
|
||||
const now = new Date();
|
||||
const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
if (month < currentMonthStr) return { created: 0 };
|
||||
|
||||
const [y, m] = month.split("-").map(Number);
|
||||
const curFrom = `${month}-01`;
|
||||
const curLastDay = new Date(y!, m!, 0).getDate();
|
||||
const curTo = `${month}-${String(curLastDay).padStart(2, "0")}`;
|
||||
|
||||
// Load active fixed cost templates for this scope
|
||||
const templateConditions = [
|
||||
eq(fixedCosts.householdId, householdId),
|
||||
eq(fixedCosts.scope, scope),
|
||||
eq(fixedCosts.isActive, true),
|
||||
];
|
||||
if (childId) templateConditions.push(eq(fixedCosts.childId, childId));
|
||||
|
||||
const templates = await db.select().from(fixedCosts).where(and(...templateConditions));
|
||||
if (templates.length === 0) return { created: 0 };
|
||||
|
||||
// Load already-activated fixed transactions for this month+scope to avoid duplicates
|
||||
const existingConditions = [
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.isFixed, true),
|
||||
eq(transactions.scope, scope),
|
||||
gte(transactions.date, curFrom),
|
||||
lte(transactions.date, curTo),
|
||||
];
|
||||
if (childId) existingConditions.push(eq(transactions.childId, childId));
|
||||
if (scope === "private") existingConditions.push(eq(transactions.userId, userId));
|
||||
|
||||
const existing = await db
|
||||
.select({ description: transactions.description })
|
||||
.from(transactions)
|
||||
.where(and(...existingConditions));
|
||||
const activatedLabels = new Set(existing.map((e) => e.description));
|
||||
|
||||
// Only insert templates not yet activated
|
||||
const rows = templates
|
||||
.filter((t) => !activatedLabels.has(t.label))
|
||||
.map((t) => ({
|
||||
householdId,
|
||||
userId,
|
||||
categoryId: t.categoryId,
|
||||
childId: t.childId,
|
||||
scope: t.scope,
|
||||
amount: t.amount,
|
||||
type: t.type,
|
||||
isFixed: true as const,
|
||||
isCarryOver: false as const,
|
||||
description: t.label,
|
||||
date: curFrom,
|
||||
}));
|
||||
|
||||
if (rows.length === 0) return { created: 0 };
|
||||
await db.insert(transactions).values(rows);
|
||||
return { created: rows.length };
|
||||
}
|
||||
|
||||
// 11b — carry-over: create a carry-over transaction in the next month
|
||||
export async function carryOverBalance(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
fromMonth: string, // "YYYY-MM"
|
||||
toMonth: string, // "YYYY-MM"
|
||||
scope: "household" | "private" | "child",
|
||||
childId?: string,
|
||||
) {
|
||||
// Guard: idempotent — check if carry-over already exists for toMonth+scope+childId
|
||||
const [ty, tm] = toMonth.split("-").map(Number);
|
||||
const toFrom = `${toMonth}-01`;
|
||||
const toLastDay = new Date(ty!, tm!, 0).getDate();
|
||||
const toTo = `${toMonth}-${String(toLastDay).padStart(2, "0")}`;
|
||||
|
||||
const existingConditions = [
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.isCarryOver, true),
|
||||
eq(transactions.scope, scope),
|
||||
gte(transactions.date, toFrom),
|
||||
lte(transactions.date, toTo),
|
||||
];
|
||||
if (childId) existingConditions.push(eq(transactions.childId, childId));
|
||||
|
||||
const existing = await db.select({ id: transactions.id }).from(transactions).where(and(...existingConditions));
|
||||
if (existing.length > 0) {
|
||||
return { alreadyExists: true, transaction: null };
|
||||
}
|
||||
|
||||
// Calculate balance for fromMonth
|
||||
const [fy, fm] = fromMonth.split("-").map(Number);
|
||||
const fromFrom = `${fromMonth}-01`;
|
||||
const fromLastDay = new Date(fy!, fm!, 0).getDate();
|
||||
const fromTo = `${fromMonth}-${String(fromLastDay).padStart(2, "0")}`;
|
||||
|
||||
const balanceConditions = [
|
||||
eq(transactions.householdId, householdId),
|
||||
eq(transactions.scope, scope),
|
||||
gte(transactions.date, fromFrom),
|
||||
lte(transactions.date, fromTo),
|
||||
];
|
||||
if (childId) balanceConditions.push(eq(transactions.childId, childId));
|
||||
if (scope === "private") balanceConditions.push(eq(transactions.userId, userId));
|
||||
|
||||
const result = await db.select({
|
||||
type: transactions.type,
|
||||
total: sql<string>`sum(${transactions.amount}::numeric)`,
|
||||
}).from(transactions).where(and(...balanceConditions)).groupBy(transactions.type);
|
||||
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
for (const row of result) {
|
||||
if (row.type === "income") income = Number(row.total ?? 0);
|
||||
else expense = Number(row.total ?? 0);
|
||||
}
|
||||
const balance = income - expense;
|
||||
|
||||
if (Math.abs(balance) < 0.01) {
|
||||
return { alreadyExists: false, transaction: null, balance: 0 };
|
||||
}
|
||||
|
||||
// Month name in German for description
|
||||
const months = ["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"];
|
||||
const fromMonthName = `${months[fm! - 1]} ${fy}`;
|
||||
const description = `Übertrag ${fromMonthName}`;
|
||||
|
||||
// balance > 0 = net income; balance < 0 = net expense
|
||||
// We carry over as a transaction that brings the slate even
|
||||
const type = balance > 0 ? "expense" : "income"; // net surplus → carry as expense in next month; net deficit → carry as income
|
||||
const amount = Math.abs(balance);
|
||||
|
||||
const [tx] = await db.insert(transactions).values({
|
||||
householdId,
|
||||
userId,
|
||||
categoryId: null,
|
||||
childId: childId ?? null,
|
||||
scope,
|
||||
amount: String(amount),
|
||||
currency: "EUR",
|
||||
type,
|
||||
isFixed: false,
|
||||
isCarryOver: true,
|
||||
description,
|
||||
date: toFrom, // 1st of toMonth
|
||||
}).returning();
|
||||
|
||||
return { alreadyExists: false, transaction: tx, balance };
|
||||
}
|
||||
|
||||
export async function getTransactionSummary(
|
||||
householdId: string,
|
||||
userId: string,
|
||||
month: Date,
|
||||
scope?: "household" | "private" | "child",
|
||||
) {
|
||||
const year = month.getFullYear();
|
||||
const monthNum = month.getMonth() + 1;
|
||||
const from = `${year}-${String(monthNum).padStart(2, "0")}-01`;
|
||||
const lastDay = new Date(year, monthNum, 0).getDate();
|
||||
const to = `${year}-${String(monthNum).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
|
||||
|
||||
const conditions = [
|
||||
eq(transactions.householdId, householdId),
|
||||
gte(transactions.date, from),
|
||||
lte(transactions.date, to),
|
||||
];
|
||||
|
||||
if (scope === "private") {
|
||||
conditions.push(eq(transactions.scope, "private"));
|
||||
conditions.push(eq(transactions.userId, userId));
|
||||
} else if (scope) {
|
||||
conditions.push(eq(transactions.scope, scope));
|
||||
} else {
|
||||
conditions.push(
|
||||
or(
|
||||
eq(transactions.scope, "household"),
|
||||
eq(transactions.scope, "child"),
|
||||
and(eq(transactions.scope, "private"), eq(transactions.userId, userId)),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
type: transactions.type,
|
||||
total: sql<string>`sum(${transactions.amount}::numeric)`,
|
||||
})
|
||||
.from(transactions)
|
||||
.where(and(...conditions))
|
||||
.groupBy(transactions.type);
|
||||
|
||||
let income = 0;
|
||||
let expense = 0;
|
||||
for (const row of result) {
|
||||
if (row.type === "income") income = Number(row.total ?? 0);
|
||||
else expense = Number(row.total ?? 0);
|
||||
}
|
||||
|
||||
return { income, expense, balance: income - expense };
|
||||
}
|
||||
431
apps/server/src/services/trips.service.ts
Normal file
431
apps/server/src/services/trips.service.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import { db, eq, and, desc, sql } from "@haushaltsApp/db";
|
||||
import { trips, tripExpenses } from "@haushaltsApp/db/schema";
|
||||
import { member, user } from "@haushaltsApp/db/schema";
|
||||
import type {
|
||||
CreateTripInput,
|
||||
UpdateTripInput,
|
||||
CreateTripExpenseInput,
|
||||
UpdateTripExpenseInput,
|
||||
TripCategory,
|
||||
} from "@haushaltsApp/shared/schemas/trips";
|
||||
import { TRIP_CATEGORIES } from "@haushaltsApp/shared/schemas/trips";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type TripRow = typeof trips.$inferSelect;
|
||||
export type TripExpenseRow = typeof tripExpenses.$inferSelect;
|
||||
|
||||
export type TripWithSpent = {
|
||||
id: string;
|
||||
householdId: string;
|
||||
name: string;
|
||||
destination: string | null;
|
||||
budget: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: string;
|
||||
spent: number;
|
||||
remaining: number;
|
||||
settlementFromUserId: string | null;
|
||||
settlementToUserId: string | null;
|
||||
settlementAmount: number | null;
|
||||
settledAt: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type TripSettlement = {
|
||||
total: number;
|
||||
fairShare: number;
|
||||
balances: Array<{ userId: string; name: string; paid: number; fairShare: number; balance: number }>;
|
||||
settlement: {
|
||||
from: string;
|
||||
fromName: string;
|
||||
to: string;
|
||||
toName: string;
|
||||
amount: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type TripExpense = {
|
||||
id: string;
|
||||
tripId: string;
|
||||
householdId: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
paidBy: string;
|
||||
date: string;
|
||||
note: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export type TripSummary = {
|
||||
trip: TripWithSpent;
|
||||
totalSpent: number;
|
||||
remaining: number;
|
||||
byCategory: Record<TripCategory, number>;
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getSpentAmount(tripId: string): Promise<number> {
|
||||
const result = await db
|
||||
.select({ total: sql<string>`coalesce(sum(${tripExpenses.amount}), '0')` })
|
||||
.from(tripExpenses)
|
||||
.where(eq(tripExpenses.tripId, tripId));
|
||||
return parseFloat(result[0]?.total ?? "0");
|
||||
}
|
||||
|
||||
function mapTripRow(row: TripRow, spent: number): TripWithSpent {
|
||||
const budget = parseFloat(row.budget);
|
||||
return {
|
||||
id: row.id,
|
||||
householdId: row.householdId,
|
||||
name: row.name,
|
||||
destination: row.destination,
|
||||
budget,
|
||||
startDate: row.startDate,
|
||||
endDate: row.endDate,
|
||||
status: row.status,
|
||||
spent,
|
||||
remaining: budget - spent,
|
||||
settlementFromUserId: row.settlementFromUserId ?? null,
|
||||
settlementToUserId: row.settlementToUserId ?? null,
|
||||
settlementAmount: row.settlementAmount ? parseFloat(row.settlementAmount) : null,
|
||||
settledAt: row.settledAt ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function mapExpenseRow(row: TripExpenseRow): TripExpense {
|
||||
return {
|
||||
id: row.id,
|
||||
tripId: row.tripId,
|
||||
householdId: row.householdId,
|
||||
label: row.label,
|
||||
amount: parseFloat(row.amount),
|
||||
category: row.category,
|
||||
paidBy: row.paidBy,
|
||||
date: row.date,
|
||||
note: row.note,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Settlement helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export function calculateTripSettlement(
|
||||
expenses: TripExpenseRow[],
|
||||
members: Array<{ userId: string; name: string }>,
|
||||
): TripSettlement {
|
||||
const total = expenses.reduce((sum, e) => sum + parseFloat(e.amount), 0);
|
||||
const fairShare = members.length > 0 ? total / members.length : 0;
|
||||
|
||||
const paidByUser: Record<string, number> = {};
|
||||
for (const expense of expenses) {
|
||||
paidByUser[expense.paidBy] = (paidByUser[expense.paidBy] ?? 0) + parseFloat(expense.amount);
|
||||
}
|
||||
|
||||
const balances = members.map((m) => {
|
||||
const paid = paidByUser[m.userId] ?? 0;
|
||||
return {
|
||||
userId: m.userId,
|
||||
name: m.name,
|
||||
paid,
|
||||
fairShare,
|
||||
balance: paid - fairShare,
|
||||
};
|
||||
});
|
||||
|
||||
const debtor = balances.find((b) => b.balance < 0) ?? null;
|
||||
const creditor = balances.find((b) => b.balance > 0) ?? null;
|
||||
|
||||
let settlement: TripSettlement["settlement"] = null;
|
||||
if (debtor && creditor && Math.abs(debtor.balance) >= 0.01) {
|
||||
settlement = {
|
||||
from: debtor.userId,
|
||||
fromName: debtor.name,
|
||||
to: creditor.userId,
|
||||
toName: creditor.name,
|
||||
amount: Math.round(Math.abs(debtor.balance) * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
return { total, fairShare, balances, settlement };
|
||||
}
|
||||
|
||||
export async function getTripSettlementPreview(
|
||||
tripId: string,
|
||||
householdId: string,
|
||||
): Promise<TripSettlement | null> {
|
||||
const [tripRow] = await db
|
||||
.select()
|
||||
.from(trips)
|
||||
.where(and(eq(trips.id, tripId), eq(trips.householdId, householdId)));
|
||||
|
||||
if (!tripRow) return null;
|
||||
|
||||
const expenses = await db
|
||||
.select()
|
||||
.from(tripExpenses)
|
||||
.where(eq(tripExpenses.tripId, tripId));
|
||||
|
||||
const members = await db
|
||||
.select({ userId: member.userId, name: user.name })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, householdId));
|
||||
|
||||
return calculateTripSettlement(expenses, members);
|
||||
}
|
||||
|
||||
// ── Service functions ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function getTrips(householdId: string): Promise<TripWithSpent[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(trips)
|
||||
.where(eq(trips.householdId, householdId))
|
||||
.orderBy(desc(trips.startDate));
|
||||
|
||||
return Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const spent = await getSpentAmount(row.id);
|
||||
return mapTripRow(row, spent);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTrip(householdId: string, input: CreateTripInput): Promise<TripWithSpent> {
|
||||
const [row] = await db
|
||||
.insert(trips)
|
||||
.values({
|
||||
householdId,
|
||||
name: input.name,
|
||||
destination: input.destination ?? null,
|
||||
budget: String(input.budget),
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapTripRow(row!, 0);
|
||||
}
|
||||
|
||||
export async function updateTrip(
|
||||
id: string,
|
||||
householdId: string,
|
||||
input: UpdateTripInput,
|
||||
): Promise<TripWithSpent | null> {
|
||||
const updateValues: Partial<typeof trips.$inferInsert> = {};
|
||||
if (input.name !== undefined) updateValues.name = input.name;
|
||||
if (input.destination !== undefined) updateValues.destination = input.destination ?? null;
|
||||
if (input.budget !== undefined) updateValues.budget = String(input.budget);
|
||||
if (input.startDate !== undefined) updateValues.startDate = input.startDate;
|
||||
if (input.endDate !== undefined) updateValues.endDate = input.endDate;
|
||||
|
||||
const [row] = await db
|
||||
.update(trips)
|
||||
.set(updateValues)
|
||||
.where(and(eq(trips.id, id), eq(trips.householdId, householdId)))
|
||||
.returning();
|
||||
|
||||
if (!row) return null;
|
||||
const spent = await getSpentAmount(id);
|
||||
return mapTripRow(row, spent);
|
||||
}
|
||||
|
||||
export async function deleteTrip(id: string, householdId: string): Promise<boolean> {
|
||||
// Check for expenses before deleting
|
||||
const [expenseCheck] = await db
|
||||
.select({ id: tripExpenses.id })
|
||||
.from(tripExpenses)
|
||||
.where(eq(tripExpenses.tripId, id))
|
||||
.limit(1);
|
||||
|
||||
if (expenseCheck) {
|
||||
throw new Error("Has expenses");
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.delete(trips)
|
||||
.where(and(eq(trips.id, id), eq(trips.householdId, householdId)))
|
||||
.returning({ id: trips.id });
|
||||
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
export async function completeTrip(id: string, householdId: string): Promise<TripWithSpent | null> {
|
||||
const expenses = await db
|
||||
.select()
|
||||
.from(tripExpenses)
|
||||
.where(eq(tripExpenses.tripId, id));
|
||||
|
||||
const members = await db
|
||||
.select({ userId: member.userId, name: user.name })
|
||||
.from(member)
|
||||
.innerJoin(user, eq(member.userId, user.id))
|
||||
.where(eq(member.organizationId, householdId));
|
||||
|
||||
const settlementResult = calculateTripSettlement(expenses, members);
|
||||
const { settlement } = settlementResult;
|
||||
|
||||
const [row] = await db
|
||||
.update(trips)
|
||||
.set({
|
||||
status: "completed",
|
||||
settlementFromUserId: settlement?.from ?? null,
|
||||
settlementToUserId: settlement?.to ?? null,
|
||||
settlementAmount: settlement ? String(settlement.amount) : null,
|
||||
settledAt: new Date().toISOString(),
|
||||
})
|
||||
.where(and(eq(trips.id, id), eq(trips.householdId, householdId)))
|
||||
.returning();
|
||||
|
||||
if (!row) return null;
|
||||
const spent = await getSpentAmount(id);
|
||||
return mapTripRow(row, spent);
|
||||
}
|
||||
|
||||
export async function getTripExpenses(tripId: string, householdId: string): Promise<TripExpense[]> {
|
||||
// Verify trip belongs to household
|
||||
const [trip] = await db
|
||||
.select({ id: trips.id })
|
||||
.from(trips)
|
||||
.where(and(eq(trips.id, tripId), eq(trips.householdId, householdId)));
|
||||
|
||||
if (!trip) return [];
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(tripExpenses)
|
||||
.where(eq(tripExpenses.tripId, tripId))
|
||||
.orderBy(desc(tripExpenses.date));
|
||||
|
||||
return rows.map(mapExpenseRow);
|
||||
}
|
||||
|
||||
export async function createTripExpense(
|
||||
tripId: string,
|
||||
householdId: string,
|
||||
input: CreateTripExpenseInput,
|
||||
): Promise<TripExpense> {
|
||||
// Verify trip belongs to household
|
||||
const [trip] = await db
|
||||
.select({ id: trips.id })
|
||||
.from(trips)
|
||||
.where(and(eq(trips.id, tripId), eq(trips.householdId, householdId)));
|
||||
|
||||
if (!trip) throw new Error("Trip not found");
|
||||
|
||||
const [row] = await db
|
||||
.insert(tripExpenses)
|
||||
.values({
|
||||
tripId,
|
||||
householdId,
|
||||
label: input.label,
|
||||
amount: String(input.amount),
|
||||
category: input.category ?? "sonstiges",
|
||||
paidBy: input.paidBy,
|
||||
date: input.date,
|
||||
note: input.note ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapExpenseRow(row!);
|
||||
}
|
||||
|
||||
export async function updateTripExpense(
|
||||
expenseId: string,
|
||||
tripId: string,
|
||||
householdId: string,
|
||||
input: UpdateTripExpenseInput,
|
||||
): Promise<TripExpense | null> {
|
||||
// Verify trip belongs to household
|
||||
const [trip] = await db
|
||||
.select({ id: trips.id })
|
||||
.from(trips)
|
||||
.where(and(eq(trips.id, tripId), eq(trips.householdId, householdId)));
|
||||
|
||||
if (!trip) return null;
|
||||
|
||||
const updateValues: Partial<typeof tripExpenses.$inferInsert> = {};
|
||||
if (input.label !== undefined) updateValues.label = input.label;
|
||||
if (input.amount !== undefined) updateValues.amount = String(input.amount);
|
||||
if (input.category !== undefined) updateValues.category = input.category;
|
||||
if (input.paidBy !== undefined) updateValues.paidBy = input.paidBy;
|
||||
if (input.date !== undefined) updateValues.date = input.date;
|
||||
if (input.note !== undefined) updateValues.note = input.note ?? null;
|
||||
|
||||
const [row] = await db
|
||||
.update(tripExpenses)
|
||||
.set(updateValues)
|
||||
.where(and(eq(tripExpenses.id, expenseId), eq(tripExpenses.tripId, tripId)))
|
||||
.returning();
|
||||
|
||||
if (!row) return null;
|
||||
return mapExpenseRow(row);
|
||||
}
|
||||
|
||||
export async function deleteTripExpense(
|
||||
expenseId: string,
|
||||
tripId: string,
|
||||
householdId: string,
|
||||
): Promise<boolean> {
|
||||
// Verify trip belongs to household
|
||||
const [trip] = await db
|
||||
.select({ id: trips.id })
|
||||
.from(trips)
|
||||
.where(and(eq(trips.id, tripId), eq(trips.householdId, householdId)));
|
||||
|
||||
if (!trip) return false;
|
||||
|
||||
const result = await db
|
||||
.delete(tripExpenses)
|
||||
.where(and(eq(tripExpenses.id, expenseId), eq(tripExpenses.tripId, tripId)))
|
||||
.returning({ id: tripExpenses.id });
|
||||
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
export async function getTripSummary(tripId: string, householdId: string): Promise<TripSummary | null> {
|
||||
const [tripRow] = await db
|
||||
.select()
|
||||
.from(trips)
|
||||
.where(and(eq(trips.id, tripId), eq(trips.householdId, householdId)));
|
||||
|
||||
if (!tripRow) return null;
|
||||
|
||||
const expenses = await db
|
||||
.select()
|
||||
.from(tripExpenses)
|
||||
.where(eq(tripExpenses.tripId, tripId));
|
||||
|
||||
const byCategory = Object.fromEntries(
|
||||
TRIP_CATEGORIES.map((cat) => [cat, 0]),
|
||||
) as Record<TripCategory, number>;
|
||||
|
||||
let totalSpent = 0;
|
||||
for (const expense of expenses) {
|
||||
const amount = parseFloat(expense.amount);
|
||||
totalSpent += amount;
|
||||
const cat = expense.category as TripCategory;
|
||||
if (cat in byCategory) {
|
||||
byCategory[cat] += amount;
|
||||
} else {
|
||||
byCategory["sonstiges"] += amount;
|
||||
}
|
||||
}
|
||||
|
||||
const budget = parseFloat(tripRow.budget);
|
||||
const trip = mapTripRow(tripRow, totalSpent);
|
||||
|
||||
return {
|
||||
trip,
|
||||
totalSpent,
|
||||
remaining: budget - totalSpent,
|
||||
byCategory,
|
||||
};
|
||||
}
|
||||
24
apps/server/src/websocket/shopping-list.ws.ts
Normal file
24
apps/server/src/websocket/shopping-list.ws.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// WebSocket handler for real-time shopping list sync
|
||||
// Used when household has pro/family plan with realtimeSync feature
|
||||
|
||||
export type WSMessage =
|
||||
| { type: "item_added"; payload: { listId: string; itemId: string } }
|
||||
| { type: "item_checked"; payload: { listId: string; itemId: string; isChecked: boolean } }
|
||||
| { type: "item_removed"; payload: { listId: string; itemId: string } };
|
||||
|
||||
// TODO: Implement WebSocket handler using Hono's websocket upgrade
|
||||
// This will be used with Hono's upgradeWebSocket when available in Bun
|
||||
export function createShoppingListWSHandler() {
|
||||
// Placeholder for WebSocket implementation
|
||||
return {
|
||||
onOpen: (_ws: unknown) => {
|
||||
console.log("Shopping list WebSocket connected");
|
||||
},
|
||||
onMessage: (_ws: unknown, _message: WSMessage) => {
|
||||
// Broadcast to all connected clients in the same household
|
||||
},
|
||||
onClose: (_ws: unknown) => {
|
||||
console.log("Shopping list WebSocket disconnected");
|
||||
},
|
||||
};
|
||||
}
|
||||
99
apps/server/src/ws/shopping-ws.ts
Normal file
99
apps/server/src/ws/shopping-ws.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { db, eq, and, isNotNull } from "@haushaltsApp/db";
|
||||
import { shoppingItems } from "@haushaltsApp/db/schema";
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import type { ShoppingServerEvent, ShoppingClientCommand } from "@haushaltsApp/shared/schemas/shopping.schema";
|
||||
import {
|
||||
getShoppingItems,
|
||||
addShoppingItem,
|
||||
checkShoppingItem,
|
||||
uncheckShoppingItem,
|
||||
deleteShoppingItem,
|
||||
} from "../services/shopping.service";
|
||||
|
||||
type WsData = { householdId: string; userId: string };
|
||||
|
||||
// ── Room Management ────────────────────────────────────────────────────────────
|
||||
|
||||
const rooms = new Map<string, Set<ServerWebSocket<WsData>>>();
|
||||
|
||||
function joinRoom(householdId: string, ws: ServerWebSocket<WsData>): void {
|
||||
if (!rooms.has(householdId)) rooms.set(householdId, new Set());
|
||||
rooms.get(householdId)!.add(ws);
|
||||
}
|
||||
|
||||
function leaveRoom(householdId: string, ws: ServerWebSocket<WsData>): void {
|
||||
const room = rooms.get(householdId);
|
||||
if (!room) return;
|
||||
room.delete(ws);
|
||||
if (room.size === 0) rooms.delete(householdId);
|
||||
}
|
||||
|
||||
export function broadcast(
|
||||
householdId: string,
|
||||
event: ShoppingServerEvent,
|
||||
exclude?: ServerWebSocket<WsData>,
|
||||
): void {
|
||||
const sockets = rooms.get(householdId);
|
||||
if (!sockets) return;
|
||||
const payload = JSON.stringify(event);
|
||||
for (const ws of sockets) {
|
||||
if (ws !== exclude && ws.readyState === 1) {
|
||||
ws.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WebSocket Handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
export const shoppingWsHandlers = {
|
||||
async open(ws: ServerWebSocket<WsData>) {
|
||||
const { householdId } = ws.data;
|
||||
joinRoom(householdId, ws);
|
||||
const items = await getShoppingItems(householdId);
|
||||
ws.send(JSON.stringify({ type: "sync", items } satisfies ShoppingServerEvent));
|
||||
},
|
||||
|
||||
async message(ws: ServerWebSocket<WsData>, rawMessage: string | Buffer) {
|
||||
const { householdId, userId } = ws.data;
|
||||
let cmd: ShoppingClientCommand;
|
||||
try {
|
||||
cmd = JSON.parse(
|
||||
typeof rawMessage === "string" ? rawMessage : rawMessage.toString(),
|
||||
) as ShoppingClientCommand;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd.type === "item:add") {
|
||||
const item = await addShoppingItem(householdId, userId, cmd.label, cmd.quantity);
|
||||
broadcast(householdId, { type: "item:added", item });
|
||||
} else if (cmd.type === "item:check") {
|
||||
const item = await checkShoppingItem(cmd.itemId, householdId, userId);
|
||||
if (item) {
|
||||
broadcast(householdId, {
|
||||
type: "item:checked",
|
||||
itemId: item.id,
|
||||
checkedBy: item.checkedBy!,
|
||||
checkedAt: item.checkedAt!,
|
||||
});
|
||||
}
|
||||
} else if (cmd.type === "item:uncheck") {
|
||||
await uncheckShoppingItem(cmd.itemId, householdId);
|
||||
broadcast(householdId, { type: "item:unchecked", itemId: cmd.itemId });
|
||||
} else if (cmd.type === "item:delete") {
|
||||
await deleteShoppingItem(cmd.itemId, householdId);
|
||||
broadcast(householdId, { type: "item:deleted", itemId: cmd.itemId });
|
||||
} else if (cmd.type === "item:clear") {
|
||||
await db
|
||||
.delete(shoppingItems)
|
||||
.where(
|
||||
and(eq(shoppingItems.householdId, householdId), isNotNull(shoppingItems.checkedBy)),
|
||||
);
|
||||
broadcast(householdId, { type: "item:cleared" });
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WsData>) {
|
||||
leaveRoom(ws.data.householdId, ws);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user