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:
René Schober
2026-03-20 11:54:22 +01:00
parent 4e34270786
commit 9ddc7c6d7a
194 changed files with 55961 additions and 305 deletions

17
apps/server/.env.example Normal file
View 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
View File

@@ -0,0 +1,2 @@
[test]
preload = ["./src/__tests__/setup.ts"]

View File

@@ -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:"
},

View 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,
};
}

View 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);
});
});

View 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");
});
});

View 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);
});
});

View 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);
});
});

View 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") });

View File

@@ -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;

View 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();
}
);

View 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();
});
}

View 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();
});

View 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));

View 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 });
});

View 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 });
});

View 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);
}
});

View 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 });
});

View 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 });
});

View 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,
});
});

View 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() });
});
}

View 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,
});
});

View 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 });
});

View 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 });
},
);

View 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 });
});

View 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 });
});

View 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" });
});

View 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 });
});

View 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 });
});

View 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 };
}

View 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;
}

View 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;
}

View 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,
};
}

View 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 };

View 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));
}

View 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!);
}

View 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;
}

View 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,
};
}

View 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 };
}

View 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,
};
}

View 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");
},
};
}

View 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);
},
};