Compare commits
25 Commits
ad80688b8b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f4005f5d4 | |||
| 0547f6a41c | |||
| 28674fb023 | |||
| b640cfdb74 | |||
| d076fba0c0 | |||
| f155a7952a | |||
| 0e62f0f80b | |||
| 29619658fc | |||
| db953b1e28 | |||
| 586d5b2cc8 | |||
| 38c8304336 | |||
| b22e5baa5c | |||
| c3e7a97c8a | |||
| fab53fc76e | |||
| f93eb0480a | |||
| ac9912fa81 | |||
| d56144b39c | |||
| f67da67abd | |||
| e526e7fd2d | |||
| c412388877 | |||
| 73ad126052 | |||
| 662e565116 | |||
| 80791e4aa7 | |||
| 8d5f69aaab | |||
| 7af907a801 |
+5
-2
@@ -1,8 +1,11 @@
|
|||||||
# Datenbank (für lokale Entwicklung)
|
# Datenbank (für lokale Entwicklung)
|
||||||
DATABASE_URL="mysql://user:password@localhost:3306/annas_rechnungsmanager"
|
DATABASE_URL="mysql://user:password@localhost:3306/annas_rechnungsmanager"
|
||||||
|
|
||||||
# Session-Secret – zufälligen Wert generieren: openssl rand -base64 32
|
# Session-Secret – optional
|
||||||
AUTH_SECRET="HIER_ZUFAELLIGEN_WERT_EINSETZEN"
|
# Wenn nicht gesetzt, wird ein zufälliger Wert generiert (empfohlen für Docker/Development)
|
||||||
|
# Bei Containerneustarts werden alle Sessions dann automatisch ungültig
|
||||||
|
# Für Production mit persistenter Session: openssl rand -base64 32
|
||||||
|
AUTH_SECRET=""
|
||||||
|
|
||||||
# Docker-Compose: Datenbank-Credentials
|
# Docker-Compose: Datenbank-Credentials
|
||||||
DB_ROOT_PASSWORD="sicheres_root_passwort"
|
DB_ROOT_PASSWORD="sicheres_root_passwort"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.henryathome.home64.de
|
||||||
|
IMAGE_NAME: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
build:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.repository_owner }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=sha,prefix=,format=short
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
no-cache: true
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Copilot Instructions — Annas Rechnungsmanager
|
||||||
|
|
||||||
|
German accounting & invoice management system for tax consultants (Steuerberater), built with React Router v7 (SSR), Prisma, MariaDB, and Tailwind CSS v4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Dev server at http://localhost:5173
|
||||||
|
npm run build # Production build
|
||||||
|
npm run typecheck # react-router typegen + tsc (run before every commit)
|
||||||
|
npm run lint # ESLint
|
||||||
|
|
||||||
|
npm run db:migrate # prisma migrate dev (create + apply migration)
|
||||||
|
npm run db:seed # Seed database with sample data
|
||||||
|
npm run db:studio # Prisma Studio GUI
|
||||||
|
|
||||||
|
npm run setup-admin # Create initial admin user
|
||||||
|
npm run reset-password
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required `.env` variables:**
|
||||||
|
```env
|
||||||
|
DATABASE_URL="mysql://root:password@localhost:3306/annas_rechnungsmanager"
|
||||||
|
AUTH_SECRET="<random 32-byte hex>" # NOT SESSION_SECRET
|
||||||
|
NODE_ENV="development"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Route Layout
|
||||||
|
|
||||||
|
Routes are **explicitly configured** in `app/routes.ts` (not auto-discovered by file name). Two layout wrappers exist:
|
||||||
|
- `dashboard-layout.tsx` — wraps all authenticated company/user routes
|
||||||
|
- `admin-layout.tsx` — wraps `/admin/*` (requires ADMIN role)
|
||||||
|
|
||||||
|
**UI routes** (`app/routes/companies.*.tsx`, etc.) export `loader` + default component.
|
||||||
|
**API routes** (`app/routes/api.*.ts`) export only `action` (no default export, no loader).
|
||||||
|
|
||||||
|
### Path Alias
|
||||||
|
|
||||||
|
`@/` resolves to `app/`. Use it everywhere: `import prisma from "@/lib/prisma.server"`.
|
||||||
|
|
||||||
|
### Data Flow Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
UI Route (loader/action)
|
||||||
|
→ app/lib/ (business logic + Prisma queries)
|
||||||
|
→ prisma.server.ts (single Prisma Client instance)
|
||||||
|
→ AuditLog (mandatory on every write)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth helpers live in `app/session.server.ts`:
|
||||||
|
- `requireUser(request)` — throws redirect to `/login` if not authenticated
|
||||||
|
- `requireAdmin(request)` — throws redirect to `/` if not ADMIN
|
||||||
|
- `getApiUser(request)` — returns user or null (for API routes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
All Zod schemas live in `app/lib/schemas.ts`. Reusable validators (`currencySchema`, `taxRateSchema`, `ibanSchema`, `vatIdSchema`) are defined there — import and extend them, don't redefine.
|
||||||
|
|
||||||
|
API routes must `safeParse` the request body before any DB access:
|
||||||
|
```ts
|
||||||
|
const parsed = mySchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tax Calculations (Never Trust the Client)
|
||||||
|
|
||||||
|
Always **recalculate amounts server-side** using `app/lib/tax.ts`:
|
||||||
|
- `calcItemAmounts(quantity, unitPrice, taxRate)` — standard MwSt.
|
||||||
|
- `calcItemAmountsKleinunternehmer(quantity, unitPrice)` — §19 UStG, no VAT
|
||||||
|
- `calcInvoiceTotals(items)` — sums net/tax/gross
|
||||||
|
|
||||||
|
Valid tax rates: `0`, `7`, `19` (enforced by `taxRateSchema`).
|
||||||
|
|
||||||
|
The `Company.kleinunternehmer` flag controls which calculation is used — always read it from the DB, not from the request body.
|
||||||
|
|
||||||
|
### Audit Logging (Mandatory on Every Write)
|
||||||
|
|
||||||
|
Every mutation must call `log()` from `app/lib/logger.server.ts`. The `action` parameter must use the `LogAction` union type defined in that file:
|
||||||
|
```ts
|
||||||
|
await log({ userId: user.id, action: "CREATE_INVOICE", entity: "Invoice", entityId: invoice.id, metadata: {...}, request });
|
||||||
|
```
|
||||||
|
Logging failures are swallowed intentionally — never let them break the main operation.
|
||||||
|
|
||||||
|
### Database Rules
|
||||||
|
|
||||||
|
- All Prisma queries belong in `app/lib/`, not in routes or components.
|
||||||
|
- Use `prisma.$transaction()` for multi-step operations (e.g., creating an invoice + a Buchung entry).
|
||||||
|
- Invoice amounts are stored as both net/tax/gross (`Decimal(10,2)`) — always persist all three.
|
||||||
|
- Soft-delete pattern: invoices use `deletedAt` field; companies use `archived`/`archivedAt`.
|
||||||
|
- When an invoice is marked PAID, a linked `Buchung` record is created automatically.
|
||||||
|
|
||||||
|
### Domain Vocabulary (German ↔ Code)
|
||||||
|
|
||||||
|
| German | Code / Table |
|
||||||
|
|--------|--------------|
|
||||||
|
| Mandant | `Company` model |
|
||||||
|
| Buchung | `Buchung` model (transaction/ledger entry) |
|
||||||
|
| Anlagegut | `Anlagegut` model (fixed asset) |
|
||||||
|
| Ausgabe | expense (type `ENTNAHME` in `Buchung`) |
|
||||||
|
| Einnahme | revenue (type `EINLAGE` in `Buchung`) |
|
||||||
|
| Kleinunternehmer | §19 UStG — no VAT on invoices |
|
||||||
|
| AFA | Absetzung für Abnutzung — depreciation, computed in `app/lib/afa.ts` |
|
||||||
|
|
||||||
|
### Sessions
|
||||||
|
|
||||||
|
Session cookie name: `__session`, expires after **4 hours**, stored via `createCookieSessionStorage`. Keys stored in session: `userId`, `userName`, `userRole`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Impact Files
|
||||||
|
|
||||||
|
Changes to these files have wide blast radius — check dependents carefully:
|
||||||
|
|
||||||
|
| File | Why it matters |
|
||||||
|
|------|----------------|
|
||||||
|
| `app/session.server.ts` | Auth used by every protected route |
|
||||||
|
| `app/lib/tax.ts` | Tax calculations used in invoices, reports, exports |
|
||||||
|
| `app/lib/afa.ts` | Depreciation logic used in reports and asset management |
|
||||||
|
| `prisma/schema.prisma` | Any change requires a migration |
|
||||||
|
| `app/routes.ts` | Route config — adding routes here is required, not automatic |
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@playwright/mcp@latest", "--browser", "chromium"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: "Copilot Setup Steps"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- .github/workflows/copilot-setup-steps.yml
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- .github/workflows/copilot-setup-steps.yml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
copilot-setup-steps:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
+10
@@ -47,3 +47,13 @@ next-env.d.ts
|
|||||||
/db/data
|
/db/data
|
||||||
|
|
||||||
/graphify-out
|
/graphify-out
|
||||||
|
|
||||||
|
# Uploaded Belege (persistent volume in production)
|
||||||
|
data/documents/
|
||||||
|
|
||||||
|
.vscode/
|
||||||
|
.react-router
|
||||||
|
.continue
|
||||||
|
.opencode
|
||||||
|
|
||||||
|
docs/superpowers
|
||||||
+9489
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16465
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
AGENTS.md
|
||||||
|
copilot-instructions.md
|
||||||
|
README.md
|
||||||
|
CLAUDE.md
|
||||||
|
IMPROVEMENTS_SUMMARY.md
|
||||||
|
INTEGRATION_EXAMPLE.md
|
||||||
|
tests/README.md
|
||||||
|
app/lib/ERROR_LOGGING_GUIDE.md
|
||||||
|
graphify-out/GRAPH_REPORT.md
|
||||||
|
data/documents/cmmoo4v5p0000ykou0bcivjsn/migr-ein-cmn4ogjc20007dfmmex6engdh-1777488241127.pdf
|
||||||
|
data/documents/cmmoo4v5p0000ykou0bcivjsn/migr-ein-cmn4ogjc20007dfmmex6engdh-1777488343334.pdf
|
||||||
|
data/documents/cmootxs7r0000nvgjmhgbanvx/demo-invoice-1-1777790365605.pdf
|
||||||
|
data/documents/cmootxs7r0000nvgjmhgbanvx/cmopen7qx000s2mzpnq44y0rb-1777790640988.pdf
|
||||||
|
data/documents/cmootxs7r0000nvgjmhgbanvx/cmopejaq8000f2mzpnho6t72z-1777790461825.pdf
|
||||||
|
public/file.svg
|
||||||
|
public/window.svg
|
||||||
|
public/globe.svg
|
||||||
|
public/next.svg
|
||||||
|
public/vercel.svg
|
||||||
|
coverage/favicon.png
|
||||||
|
coverage/sort-arrow-sprite.png
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// graphify OpenCode plugin
|
||||||
|
// Injects a knowledge graph reminder before bash tool calls when the graph exists.
|
||||||
|
import { existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
export const GraphifyPlugin = async ({ directory }) => {
|
||||||
|
let reminded = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tool.execute.before": async (input, output) => {
|
||||||
|
if (reminded) return;
|
||||||
|
if (!existsSync(join(directory, "graphify-out", "graph.json"))) return;
|
||||||
|
|
||||||
|
if (input.tool === "bash") {
|
||||||
|
output.args.command =
|
||||||
|
'echo "[graphify] Knowledge graph available. Read graphify-out/GRAPH_REPORT.md for god nodes and architecture context before searching files." && ' +
|
||||||
|
output.args.command;
|
||||||
|
reminded = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[ 421ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/favicon.ico:0
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
- generic [ref=e4]:
|
||||||
|
- generic [ref=e5]:
|
||||||
|
- img [ref=e7]
|
||||||
|
- generic [ref=e9]: Rechnungsmanager
|
||||||
|
- generic [ref=e10]:
|
||||||
|
- heading "Willkommen zurück" [level=1] [ref=e11]
|
||||||
|
- paragraph [ref=e12]: Benutzername oder E-Mail und Passwort eingeben
|
||||||
|
- generic [ref=e14]:
|
||||||
|
- generic [ref=e15]:
|
||||||
|
- text: Benutzername oder E-Mail
|
||||||
|
- textbox "Benutzername oder E-Mail" [ref=e16]:
|
||||||
|
- /placeholder: anna oder anna@example.de
|
||||||
|
- generic [ref=e17]:
|
||||||
|
- text: Passwort
|
||||||
|
- generic [ref=e18]:
|
||||||
|
- textbox "Passwort" [ref=e19]
|
||||||
|
- button "Passwort anzeigen" [ref=e20]:
|
||||||
|
- img [ref=e21]
|
||||||
|
- button "Anmelden" [ref=e24]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Annas Rechnungsmanager Agent Guidance
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
- Install: `npm install`
|
||||||
|
- Dev server: `npm run dev` (Vite on http://localhost:5173)
|
||||||
|
- Dev with DB: `npm run devfull` (starts docker-compose DB first)
|
||||||
|
- Typecheck: `npm run typecheck` (react-router typegen + tsc)
|
||||||
|
- Lint: `npm run lint` (eslint)
|
||||||
|
- Test: `npm run test` (vitest run)
|
||||||
|
- DB migrate: `npm run db:migrate` (prisma migrate dev)
|
||||||
|
- DB seed: `npm run db:seed` (loads demo data)
|
||||||
|
- DB studio: `npm run db:studio` (Prisma GUI)
|
||||||
|
- Admin setup: `npm run setup-admin` (sets ADMIN_PASSWORD env var or prompts)
|
||||||
|
- Reset password: `npm run reset-password -- --username <user> --password <pass>`
|
||||||
|
- Build: `npm run build`
|
||||||
|
- Start prod: `npm run start`
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- `.env` required: DATABASE_URL and AUTH_SECRET
|
||||||
|
- Dev: http://localhost:5173
|
||||||
|
- Docker: http://localhost:3000 (first start requires ADMIN_PASSWORD)
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
- First start: `docker build -t annasrechnungsmanager:latest .` then `docker compose up -d` with ADMIN_PASSWORD set
|
||||||
|
- Subsequent: `docker build -t annasrechnungsmanager:latest .` then `docker compose up -d --no-deps app`
|
||||||
|
- Admin user created/updated on first start with ADMIN_PASSWORD
|
||||||
|
|
||||||
|
## Code Structure
|
||||||
|
- `app/routes/` - file-based routing (React Router v7)
|
||||||
|
- `app/routes/api.*` - REST API endpoints (loader/action)
|
||||||
|
- `app/lib/` - data access, Prisma, tax, utils
|
||||||
|
- `prisma/schema.prisma` - database schema
|
||||||
|
- `scripts/` - setup-admin, reset-password
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
- TypeScript strict; use unknown/guard checks for external data
|
||||||
|
- UI: Tailwind v4 + shadcn/ui
|
||||||
|
- Error handling: Remix redirect, json, badRequest
|
||||||
|
- API contracts in `app/routes/api.*` must remain backward compatible
|
||||||
|
- Document business logic for complex changes (taxes, invoice codes, UStG §14)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Vitest: `npm run test` (run), `npm run test:watch` (watch)
|
||||||
|
- No additional services required for unit tests
|
||||||
|
|
||||||
|
## graphify
|
||||||
|
|
||||||
|
This project has a graphify knowledge graph at graphify-out/.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
|
||||||
|
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
||||||
|
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
|
||||||
+10
-2
@@ -3,7 +3,11 @@ FROM node:alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache openssl
|
RUN if command -v apk >/dev/null 2>&1; then \
|
||||||
|
apk add --no-cache openssl; \
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*; \
|
||||||
|
fi
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -25,7 +29,11 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apk add --no-cache openssl
|
RUN if command -v apk >/dev/null 2>&1; then \
|
||||||
|
apk add --no-cache openssl; \
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then \
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*; \
|
||||||
|
fi
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* INTEGRATION EXAMPLE: How to use error logging in your existing routes
|
||||||
|
* This shows the new error-logger.server in real-world usage
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Before (old code - minimal logging):
|
||||||
|
/*
|
||||||
|
export async function action({ request }: { request: Request }) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = customerSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const customer = await prisma.customer.create({ data: parsed.data });
|
||||||
|
await log({ userId: user.id, action: "CREATE_CUSTOMER", entity: "Customer", entityId: customer.id, request });
|
||||||
|
return Response.json(customer, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error); // ❌ Not helpful for debugging
|
||||||
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// After (with comprehensive error logging):
|
||||||
|
import { getApiUser } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
import { log } from "@/lib/logger.server";
|
||||||
|
import { logApiError } from "@/lib/error-logger.server";
|
||||||
|
import { customerSchema } from "@/lib/schemas";
|
||||||
|
|
||||||
|
export async function action({ request }: { request: Request }) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = customerSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await prisma.customer.create({ data: parsed.data });
|
||||||
|
await log({
|
||||||
|
userId: user.id,
|
||||||
|
action: "CREATE_CUSTOMER",
|
||||||
|
entity: "Customer",
|
||||||
|
entityId: customer.id,
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
return Response.json(customer, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
// ✅ Now with full context: stack trace, request details, metadata
|
||||||
|
logApiError(error, {
|
||||||
|
request,
|
||||||
|
endpoint: "/api/customers",
|
||||||
|
userId: user.id,
|
||||||
|
statusCode: 500,
|
||||||
|
metadata: {
|
||||||
|
method: request.method,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WHAT YOU'LL SEE IN THE SERVER LOGS NOW:
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
================================================================================
|
||||||
|
[API_ERROR] | 2026-05-02T22:15:45.789Z | duplicate entry for unique field 'email'
|
||||||
|
POST /api/customers | user: user-abc123 | ip: 192.168.1.100 | endpoint: /api/customers | statusCode: 500
|
||||||
|
Error: Customer with email 'john@example.com' already exists
|
||||||
|
at Object.create (file:///app/lib/customer.server.ts:25:13)
|
||||||
|
at action (file:///app/routes/api.customers.ts:30:7)
|
||||||
|
at processRequest (file:///app/routes/_middleware.ts:12:3)
|
||||||
|
|
||||||
|
Metadata: {
|
||||||
|
"method": "POST",
|
||||||
|
"endpoint": "/api/customers"
|
||||||
|
}
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
✓ You can now see:
|
||||||
|
- Exact error message with context
|
||||||
|
- HTTP method & endpoint
|
||||||
|
- User ID & IP address
|
||||||
|
- Stack trace for debugging
|
||||||
|
- Custom metadata
|
||||||
|
- Timestamp
|
||||||
|
*/
|
||||||
@@ -105,6 +105,42 @@ Migrationen werden beim Start automatisch angewendet. `ADMIN_PASSWORD` ist nicht
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## CI/CD (Gitea Actions)
|
||||||
|
|
||||||
|
Die Pipeline liegt in `.gitea/workflows/build.yml` und baut/pusht bei jedem Push auf `main` ein Docker-Image.
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Secret `REGISTRY_TOKEN` ist im Repository gesetzt
|
||||||
|
- Der Token hat Berechtigung zum Push in die Gitea Container Registry
|
||||||
|
- Der Registry-Host ist vom Runner aus erreichbar
|
||||||
|
|
||||||
|
### Aktuelle Registry-Konfiguration
|
||||||
|
|
||||||
|
Die Pipeline nutzt aktuell:
|
||||||
|
|
||||||
|
- `REGISTRY=git.henryathome.home64.de`
|
||||||
|
- Image-Name aus `${{ gitea.repository }}`
|
||||||
|
- Tags: kurzer Commit-SHA + `latest` (auf Default-Branch)
|
||||||
|
|
||||||
|
### Häufiger Fehler: `i/o timeout` beim Push
|
||||||
|
|
||||||
|
Fehlerbild (vereinfacht):
|
||||||
|
|
||||||
|
`failed to push ... Head "https://.../v2/.../blobs/...": dial tcp ...:443: i/o timeout`
|
||||||
|
|
||||||
|
Typische Ursache:
|
||||||
|
|
||||||
|
- Die Registry-Adresse ist intern (z. B. `*.svc.cluster.local`) und vom CI-Runner nicht erreichbar.
|
||||||
|
|
||||||
|
Lösung:
|
||||||
|
|
||||||
|
- In `.gitea/workflows/build.yml` einen extern erreichbaren Host bei `REGISTRY` eintragen.
|
||||||
|
- Sicherstellen, dass DNS, Firewall und Portfreigaben vom Runner zur Registry passen.
|
||||||
|
- Prüfen, ob HTTPS korrekt terminiert ist (Zertifikat/Reverse Proxy), da Buildx standardmäßig per HTTPS pusht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Admin-Benutzer & Recovery
|
## Admin-Benutzer & Recovery
|
||||||
|
|
||||||
### Admin-Passwort setzen (im laufenden Container)
|
### Admin-Passwort setzen (im laufenden Container)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { z } from "zod";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { debugLog, handleApiError } from "@/lib/client-validation";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, "Name ist erforderlich"),
|
name: z.string().min(1, "Name ist erforderlich"),
|
||||||
@@ -32,37 +34,193 @@ interface CompanyFormProps {
|
|||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
|
function Field({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
tooltip,
|
||||||
|
required = false,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
required?: boolean;
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
const [showRequiredTooltip, setShowRequiredTooltip] = useState(false);
|
||||||
|
|
||||||
|
// Debug: log errors to console
|
||||||
|
if (error) {
|
||||||
|
console.log(`[Field Error] ${label}: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>{label}</Label>
|
<Label className="flex items-center gap-1">
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span className="inline-flex items-center group relative">
|
||||||
|
<span className="text-red-500 font-bold text-lg leading-none">*</span>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-red-600 text-white text-xs rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
|
||||||
|
>
|
||||||
|
Erforderliches Feld
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className={required && !error ? "relative" : ""}>
|
||||||
{children}
|
{children}
|
||||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
</div>
|
||||||
|
{/* Show error tooltip ONLY when there's an error */}
|
||||||
|
{error && (
|
||||||
|
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700 flex gap-2">
|
||||||
|
<svg className="h-4 w-4 text-red-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{error}</p>
|
||||||
|
{tooltip && <p className="text-red-600 mt-0.5">{tooltip}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) {
|
export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) {
|
||||||
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
|
const [apiError, setApiError] = useState<string | null>(null);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
const { register, handleSubmit, formState: { errors, isSubmitting, isValid }, watch, trigger } = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
|
mode: "onBlur", // Validate when user leaves field
|
||||||
defaultValues: { country: "DE", invoicePrefix: "RE", kleinunternehmer: false, ...defaultValues },
|
defaultValues: { country: "DE", invoicePrefix: "RE", kleinunternehmer: false, ...defaultValues },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Debug: log all errors
|
||||||
|
useEffect(() => {
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
console.log("[Form Errors]", errors);
|
||||||
|
}
|
||||||
|
}, [errors]);
|
||||||
|
|
||||||
|
// Watch form data for debug logging
|
||||||
|
const formData = watch();
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: FormData) => {
|
||||||
|
// Trigger validation to check if form is actually valid
|
||||||
|
const isFormValid = await trigger();
|
||||||
|
|
||||||
|
if (!isFormValid) {
|
||||||
|
// Build error message from validation errors
|
||||||
|
const errorFields: string[] = [];
|
||||||
|
|
||||||
|
if (errors.name) errorFields.push("Firmenname");
|
||||||
|
if (errors.address) errorFields.push("Adresse");
|
||||||
|
if (errors.zip) errorFields.push("Postleitzahl");
|
||||||
|
if (errors.city) errorFields.push("Ort/Stadt");
|
||||||
|
if (errors.email) errorFields.push("E-Mail");
|
||||||
|
if (errors.taxId) errorFields.push("Steuernummer");
|
||||||
|
if (errors.vatId) errorFields.push("USt-IdNr.");
|
||||||
|
if (errors.bankIban) errorFields.push("IBAN");
|
||||||
|
if (errors.bankBic) errorFields.push("BIC");
|
||||||
|
|
||||||
|
const fieldList = errorFields.length > 0
|
||||||
|
? errorFields.join(", ")
|
||||||
|
: "Bitte überprüfen Sie die rot gekennzeichneten Felder";
|
||||||
|
|
||||||
|
const message = `Folgende Felder sind erforderlich oder falsch: ${fieldList}`;
|
||||||
|
setValidationError(message);
|
||||||
|
debugLog("warning", "Form validation failed", { errors: errorFields });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setApiError(null);
|
||||||
|
setValidationError(null);
|
||||||
|
debugLog("info", "Submitting form", data);
|
||||||
|
|
||||||
|
await onSubmit(data);
|
||||||
|
|
||||||
|
debugLog("success", "Form submitted successfully");
|
||||||
|
} catch (error) {
|
||||||
|
debugLog("error", "Form submission failed", error);
|
||||||
|
handleApiError(error, "/api/companies");
|
||||||
|
|
||||||
|
// Extract error message for user
|
||||||
|
if (error instanceof Response) {
|
||||||
|
try {
|
||||||
|
const json = await error.clone().json();
|
||||||
|
const messages = json.error?.map?.((e: any) => e.message)?.join(", ") ||
|
||||||
|
json.message ||
|
||||||
|
"Ein Fehler ist aufgetreten";
|
||||||
|
setApiError(messages);
|
||||||
|
} catch {
|
||||||
|
setApiError(`HTTP ${error.status}: ${error.statusText}`);
|
||||||
|
}
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
setApiError(error.message);
|
||||||
|
} else {
|
||||||
|
setApiError("Ein unbekannter Fehler ist aufgetreten");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||||
|
{/* Required fields info banner */}
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-blue-900">ℹ️ Erforderliche Felder</p>
|
||||||
|
<p className="text-sm text-blue-800 mt-1">Folgende Felder sind erforderlich: <strong>Firmenname, Adresse, Postleitzahl, Ort</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation error banner */}
|
||||||
|
{validationError && (
|
||||||
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-yellow-900">⚠️ Eingabefehler</p>
|
||||||
|
<p className="text-sm text-yellow-800 mt-1">{validationError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API error banner */}
|
||||||
|
{apiError && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-sm font-medium text-red-800">❌ Fehler</p>
|
||||||
|
<p className="text-sm text-red-700 mt-1">{apiError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Stammdaten</h3>
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Stammdaten</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Field label="Firmenname *" error={errors.name?.message}>
|
<Field
|
||||||
|
label="Firmenname"
|
||||||
|
required={true}
|
||||||
|
error={errors.name?.message}
|
||||||
|
tooltip="Name des Unternehmens oder der Geschäftseinheit"
|
||||||
|
>
|
||||||
<Input {...register("name")} placeholder="Muster GmbH" />
|
<Input {...register("name")} placeholder="Muster GmbH" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Rechtsform" error={errors.legalForm?.message}>
|
<Field
|
||||||
|
label="Rechtsform"
|
||||||
|
error={errors.legalForm?.message}
|
||||||
|
tooltip="z.B. GmbH, AG, UG, Einzelunternehmen"
|
||||||
|
>
|
||||||
<Input {...register("legalForm")} placeholder="GmbH, AG, UG..." />
|
<Input {...register("legalForm")} placeholder="GmbH, AG, UG..." />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Steuernummer" error={errors.taxId?.message}>
|
<Field
|
||||||
|
label="Steuernummer"
|
||||||
|
error={errors.taxId?.message}
|
||||||
|
tooltip="10-stellige deutsche Steuernummer (z.B. 123/456/78901)"
|
||||||
|
>
|
||||||
<Input {...register("taxId")} placeholder="123/456/78901" />
|
<Input {...register("taxId")} placeholder="123/456/78901" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="USt-IdNr." error={errors.vatId?.message}>
|
<Field
|
||||||
|
label="USt-IdNr."
|
||||||
|
error={errors.vatId?.message}
|
||||||
|
tooltip="Umsatzsteuer-Identifikationsnummer (z.B. DE123456789)"
|
||||||
|
>
|
||||||
<Input {...register("vatId")} placeholder="DE123456789" />
|
<Input {...register("vatId")} placeholder="DE123456789" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,14 +230,29 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
|
|||||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Anschrift</h3>
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Anschrift</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Field label="Straße & Hausnummer *" error={errors.address?.message}>
|
<Field
|
||||||
|
label="Straße & Hausnummer"
|
||||||
|
required={true}
|
||||||
|
error={errors.address?.message}
|
||||||
|
tooltip="Vollständige Adresse mit Straße und Hausnummer"
|
||||||
|
>
|
||||||
<Input {...register("address")} placeholder="Musterstraße 1" />
|
<Input {...register("address")} placeholder="Musterstraße 1" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="PLZ *" error={errors.zip?.message}>
|
<Field
|
||||||
|
label="PLZ"
|
||||||
|
required={true}
|
||||||
|
error={errors.zip?.message}
|
||||||
|
tooltip="Deutsche Postleitzahl (5-stellig)"
|
||||||
|
>
|
||||||
<Input {...register("zip")} placeholder="10115" />
|
<Input {...register("zip")} placeholder="10115" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Ort *" error={errors.city?.message}>
|
<Field
|
||||||
|
label="Ort"
|
||||||
|
required={true}
|
||||||
|
error={errors.city?.message}
|
||||||
|
tooltip="Stadt oder Gemeinde"
|
||||||
|
>
|
||||||
<Input {...register("city")} placeholder="Berlin" />
|
<Input {...register("city")} placeholder="Berlin" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,13 +261,25 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Kontakt</h3>
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Kontakt</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Field label="E-Mail" error={errors.email?.message}>
|
<Field
|
||||||
|
label="E-Mail"
|
||||||
|
error={errors.email?.message}
|
||||||
|
tooltip="Kontakt-E-Mail-Adresse (optional)"
|
||||||
|
>
|
||||||
<Input {...register("email")} type="email" placeholder="info@firma.de" />
|
<Input {...register("email")} type="email" placeholder="info@firma.de" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Telefon" error={errors.phone?.message}>
|
<Field
|
||||||
|
label="Telefon"
|
||||||
|
error={errors.phone?.message}
|
||||||
|
tooltip="Telefonnummer mit Landesvorwahl (optional)"
|
||||||
|
>
|
||||||
<Input {...register("phone")} placeholder="+49 30 12345678" />
|
<Input {...register("phone")} placeholder="+49 30 12345678" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Website" error={errors.website?.message}>
|
<Field
|
||||||
|
label="Website"
|
||||||
|
error={errors.website?.message}
|
||||||
|
tooltip="URL der Unternehmenswebseite (optional)"
|
||||||
|
>
|
||||||
<Input {...register("website")} placeholder="https://firma.de" />
|
<Input {...register("website")} placeholder="https://firma.de" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,14 +289,26 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
|
|||||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Bankverbindung</h3>
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Bankverbindung</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Field label="IBAN" error={errors.bankIban?.message}>
|
<Field
|
||||||
|
label="IBAN"
|
||||||
|
error={errors.bankIban?.message}
|
||||||
|
tooltip="Internationale Kontonummer (z.B. DE89 3704 0044...) (optional)"
|
||||||
|
>
|
||||||
<Input {...register("bankIban")} placeholder="DE89 3704 0044 0532 0130 00" />
|
<Input {...register("bankIban")} placeholder="DE89 3704 0044 0532 0130 00" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="BIC" error={errors.bankBic?.message}>
|
<Field
|
||||||
|
label="BIC"
|
||||||
|
error={errors.bankBic?.message}
|
||||||
|
tooltip="Bank Identifier Code (z.B. COBADEFFXXX) (optional)"
|
||||||
|
>
|
||||||
<Input {...register("bankBic")} placeholder="COBADEFFXXX" />
|
<Input {...register("bankBic")} placeholder="COBADEFFXXX" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Kreditinstitut" error={errors.bankName?.message}>
|
<Field
|
||||||
|
label="Kreditinstitut"
|
||||||
|
error={errors.bankName?.message}
|
||||||
|
tooltip="Name der Bank (optional)"
|
||||||
|
>
|
||||||
<Input {...register("bankName")} placeholder="Commerzbank" />
|
<Input {...register("bankName")} placeholder="Commerzbank" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +317,11 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Rechnungseinstellungen</h3>
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Rechnungseinstellungen</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Field label="Rechnungsnummern-Präfix" error={errors.invoicePrefix?.message}>
|
<Field
|
||||||
|
label="Rechnungsnummern-Präfix"
|
||||||
|
error={errors.invoicePrefix?.message}
|
||||||
|
tooltip="Präfix für Rechnungsnummern (z.B. RE oder INV)"
|
||||||
|
>
|
||||||
<Input {...register("invoicePrefix")} placeholder="RE" />
|
<Input {...register("invoicePrefix")} placeholder="RE" />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,8 +340,8 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<Button type="submit" disabled={isSubmitting || !isValid}>
|
||||||
{isSubmitting ? "Speichern..." : submitLabel}
|
{isSubmitting ? "Speichern..." : submitLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Debug panel component - shows in development and when debug mode is enabled
|
||||||
|
* Access via browser console: setDebugMode(true) or localStorage.setItem('DEBUG_MODE', 'true')
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { isDebugMode, setDebugMode } from "@/lib/client-validation";
|
||||||
|
|
||||||
|
export function DebugPanel() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [debugEnabled, setDebugEnabled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDebugEnabled(isDebugMode());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleDebug = () => {
|
||||||
|
const newState = !debugEnabled;
|
||||||
|
setDebugMode(newState);
|
||||||
|
setDebugEnabled(newState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only show in development
|
||||||
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`fixed bottom-4 right-4 z-40 w-12 h-12 rounded-full font-bold text-white text-lg transition-all ${
|
||||||
|
debugEnabled
|
||||||
|
? "bg-blue-600 hover:bg-blue-700"
|
||||||
|
: "bg-gray-400 hover:bg-gray-500"
|
||||||
|
}`}
|
||||||
|
title={debugEnabled ? "Debug Mode: ON" : "Debug Mode: OFF"}
|
||||||
|
>
|
||||||
|
🐛
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Debug panel */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="fixed bottom-20 right-4 z-40 bg-gray-900 text-white rounded-lg shadow-2xl p-4 w-80 max-h-96 overflow-auto">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-bold text-lg">Debug Mode</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="text-gray-400 hover:text-white"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-700 pt-3">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={debugEnabled}
|
||||||
|
onChange={toggleDebug}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
{debugEnabled ? "✅ Enabled" : "⭕ Disabled"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 p-3 rounded text-xs space-y-1">
|
||||||
|
<p className="text-gray-400">Browser Console Commands:</p>
|
||||||
|
<code className="block text-blue-400">
|
||||||
|
setDebugMode(true)
|
||||||
|
</code>
|
||||||
|
<code className="block text-blue-400">
|
||||||
|
localStorage.setItem('DEBUG_MODE', 'true')
|
||||||
|
</code>
|
||||||
|
<code className="block text-green-400">
|
||||||
|
isDebugMode()
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 p-3 rounded text-xs">
|
||||||
|
<p className="text-gray-400 mb-1">Current State:</p>
|
||||||
|
<p className="text-yellow-300">
|
||||||
|
DEBUG: <strong>{debugEnabled ? "ON" : "OFF"}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-yellow-300">
|
||||||
|
ENV: <strong>{process.env.NODE_ENV}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log("🔍 Debug Info:");
|
||||||
|
console.log({
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert("Enable Debug Mode first!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded text-sm font-medium"
|
||||||
|
>
|
||||||
|
Log Debug Info
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { calcItemAmounts, calcItemAmountsKleinunternehmer, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax";
|
import { calcItemAmounts, calcItemAmountsKleinunternehmer, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
|
|
||||||
interface Customer {
|
interface Customer {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -301,7 +300,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
|
|||||||
value={descValue}
|
value={descValue}
|
||||||
onChange={(e) => setValue(`items.${index}.description`, e.target.value)}
|
onChange={(e) => setValue(`items.${index}.description`, e.target.value)}
|
||||||
onFocus={() => setOpenDropdown(index)}
|
onFocus={() => setOpenDropdown(index)}
|
||||||
onBlur={() => setOpenDropdown(null)}
|
onBlur={() => setTimeout(() => setOpenDropdown(null), 100)}
|
||||||
placeholder="Leistungsbeschreibung"
|
placeholder="Leistungsbeschreibung"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -315,7 +314,7 @@ export function InvoiceForm({ customers, companyId, onSubmit, defaultValues, def
|
|||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex flex-col"
|
className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex flex-col"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setValue(`items.${index}.description`, s.description ?? s.name);
|
setValue(`items.${index}.description`, s.name);
|
||||||
setValue(`items.${index}.unit`, s.unit ?? "Stück");
|
setValue(`items.${index}.unit`, s.unit ?? "Stück");
|
||||||
setValue(`items.${index}.unitPrice`, String(s.unitPrice));
|
setValue(`items.${index}.unitPrice`, String(s.unitPrice));
|
||||||
setValue(`items.${index}.taxRate`, String(s.taxRate));
|
setValue(`items.${index}.taxRate`, String(s.taxRate));
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-950 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
+18
-1
@@ -1,4 +1,6 @@
|
|||||||
import { startCleanupScheduler } from "./lib/cleanup.server";
|
import { startCleanupScheduler } from "./lib/cleanup.server";
|
||||||
|
import { initializeDatabase } from "./lib/db-init.server";
|
||||||
|
import { logStartupError, logError } from "./lib/error-logger.server";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import type { AppLoadContext, EntryContext } from "react-router";
|
import type { AppLoadContext, EntryContext } from "react-router";
|
||||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||||
@@ -8,6 +10,12 @@ import { renderToPipeableStream } from "react-dom/server";
|
|||||||
|
|
||||||
startCleanupScheduler();
|
startCleanupScheduler();
|
||||||
|
|
||||||
|
// Initialize database: run migrations on startup
|
||||||
|
initializeDatabase().catch((error) => {
|
||||||
|
logStartupError(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
const ABORT_DELAY = 5_000;
|
const ABORT_DELAY = 5_000;
|
||||||
|
|
||||||
export default function handleRequest(
|
export default function handleRequest(
|
||||||
@@ -38,11 +46,20 @@ export default function handleRequest(
|
|||||||
pipe(body);
|
pipe(body);
|
||||||
},
|
},
|
||||||
onShellError(error: unknown) {
|
onShellError(error: unknown) {
|
||||||
|
logError("SHELL_ERROR", error, {
|
||||||
|
request,
|
||||||
|
route: new URL(request.url).pathname,
|
||||||
|
});
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
onError(error: unknown) {
|
onError(error: unknown) {
|
||||||
responseStatusCode = 500;
|
responseStatusCode = 500;
|
||||||
if (shellRendered) console.error(error);
|
if (shellRendered) {
|
||||||
|
logError("RENDER_ERROR", error, {
|
||||||
|
request,
|
||||||
|
route: new URL(request.url).pathname,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Error Logging Usage Examples
|
||||||
|
*
|
||||||
|
* Use these patterns in your routes to get better error debugging
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PATTERN 1: In a loader (data fetching)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { json, type LoaderFunction } from "react-router";
|
||||||
|
import { logRouteError } from "@/lib/error-logger.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
|
||||||
|
export const loader: LoaderFunction = async ({ request, params }) => {
|
||||||
|
try {
|
||||||
|
const company = await prisma.company.findUnique({
|
||||||
|
where: { id: params.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
throw new Error(`Company not found: ${params.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ company });
|
||||||
|
} catch (error) {
|
||||||
|
logRouteError(error, {
|
||||||
|
request,
|
||||||
|
route: request.url,
|
||||||
|
userId: params.userId, // if available
|
||||||
|
metadata: {
|
||||||
|
companyId: params.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error; // Re-throw to let React Router handle it
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PATTERN 2: In an action (mutation/form submission)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { logActionError } from "@/lib/error-logger.server";
|
||||||
|
|
||||||
|
export const action: LoaderFunction = async ({ request, params }) => {
|
||||||
|
try {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const result = await prisma.company.update({
|
||||||
|
where: { id: params.id },
|
||||||
|
data: { name: formData.get("name") },
|
||||||
|
});
|
||||||
|
return json({ success: true, result });
|
||||||
|
} catch (error) {
|
||||||
|
logActionError(error, {
|
||||||
|
request,
|
||||||
|
action: "UPDATE_COMPANY",
|
||||||
|
metadata: {
|
||||||
|
companyId: params.id,
|
||||||
|
method: request.method,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PATTERN 3: In an API route (POST/PUT/DELETE)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { logApiError } from "@/lib/error-logger.server";
|
||||||
|
|
||||||
|
export const action: LoaderFunction = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (!data.name) {
|
||||||
|
return json(
|
||||||
|
{ error: "Name is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process
|
||||||
|
const result = await prisma.company.create({ data });
|
||||||
|
return json({ success: true, result }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logApiError(error, {
|
||||||
|
request,
|
||||||
|
endpoint: "/api/companies",
|
||||||
|
statusCode: 500,
|
||||||
|
metadata: {
|
||||||
|
method: request.method,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PATTERN 4: Database operations with error context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { logDatabaseError } from "@/lib/error-logger.server";
|
||||||
|
|
||||||
|
async function fetchCompanyWithUsers(companyId: string) {
|
||||||
|
try {
|
||||||
|
return await prisma.company.findUnique({
|
||||||
|
where: { id: companyId },
|
||||||
|
include: { users: true },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logDatabaseError(error, "company.findUnique", {
|
||||||
|
metadata: { companyId },
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OUTPUT EXAMPLE (Server Console)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
================================================================================
|
||||||
|
[ROUTE_ERROR] | 2026-05-02T22:10:30.123Z | Company not found | route: /companies/123 | GET /companies/123 | user: user-id-456
|
||||||
|
Stack at Object.loader (file:///app/routes/companies.$id.tsx:12:13)
|
||||||
|
at process._tickCallback (internal/timers.ts:203:26)
|
||||||
|
Metadata: {
|
||||||
|
"companyId": "123"
|
||||||
|
}
|
||||||
|
================================================================================
|
||||||
|
*/
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Client-side validation and debugging utilities
|
||||||
|
* Mirrors server-side validation for real-time user feedback
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Debug mode - enable via localStorage: localStorage.setItem('DEBUG_MODE', 'true')
|
||||||
|
export function isDebugMode(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return localStorage.getItem("DEBUG_MODE") === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDebugMode(enabled: boolean): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (enabled) {
|
||||||
|
localStorage.setItem("DEBUG_MODE", "true");
|
||||||
|
console.log("✅ DEBUG MODE ENABLED");
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("DEBUG_MODE");
|
||||||
|
console.log("❌ DEBUG MODE DISABLED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log error to browser console (only in debug mode)
|
||||||
|
*/
|
||||||
|
export function debugLog(type: string, message: string, data?: unknown): void {
|
||||||
|
if (!isDebugMode()) return;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
error: "color: #ef4444; font-weight: bold;",
|
||||||
|
warning: "color: #f97316; font-weight: bold;",
|
||||||
|
info: "color: #3b82f6; font-weight: bold;",
|
||||||
|
success: "color: #22c55e; font-weight: bold;",
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeStyle = style[type as keyof typeof style] || style.info;
|
||||||
|
console.log(`%c[${type.toUpperCase()}] ${message}`, typeStyle, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation error type
|
||||||
|
*/
|
||||||
|
export interface ValidationError {
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate tax ID (Steuernummer): 10 digits
|
||||||
|
*/
|
||||||
|
export function validateTaxId(val: string | null | undefined): ValidationError | null {
|
||||||
|
if (!val || val === "") return null;
|
||||||
|
if (!/^\d{10}$/.test(val)) {
|
||||||
|
return { field: "taxId", message: "Steuernummer muss 10 Ziffern haben" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate VAT ID (USt-IdNr): DE + 9 digits
|
||||||
|
*/
|
||||||
|
export function validateVatId(val: string | null | undefined): ValidationError | null {
|
||||||
|
if (!val || val === "") return null;
|
||||||
|
if (!/^DE\d{9}$/.test(val)) {
|
||||||
|
return { field: "vatId", message: "USt-IdNr. muss im Format DE + 9 Ziffern sein" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate IBAN
|
||||||
|
*/
|
||||||
|
export function validateIban(val: string | null | undefined): ValidationError | null {
|
||||||
|
if (!val || val === "") return null;
|
||||||
|
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(val)) {
|
||||||
|
return { field: "bankIban", message: "Ungültige IBAN" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate BIC
|
||||||
|
*/
|
||||||
|
export function validateBic(val: string | null | undefined): ValidationError | null {
|
||||||
|
if (!val || val === "") return null;
|
||||||
|
if (!/^[A-Z0-9]{8,11}$/.test(val)) {
|
||||||
|
return { field: "bankBic", message: "Ungültiger BIC" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate website URL
|
||||||
|
*/
|
||||||
|
export function validateWebsite(val: string | null | undefined): ValidationError | null {
|
||||||
|
if (!val || val === "") return null;
|
||||||
|
if (!/^https?:\/\//.test(val)) {
|
||||||
|
return { field: "website", message: "Website muss mit http:// oder https:// beginnen" };
|
||||||
|
}
|
||||||
|
if (val.length > 255) {
|
||||||
|
return { field: "website", message: "Website darf maximal 255 Zeichen sein" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate company form data
|
||||||
|
*/
|
||||||
|
export interface CompanyFormData {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
taxId?: string;
|
||||||
|
vatId?: string;
|
||||||
|
website?: string;
|
||||||
|
bankIban?: string;
|
||||||
|
bankBic?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
legalForm?: string;
|
||||||
|
country?: string;
|
||||||
|
bankName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCompanyForm(
|
||||||
|
data: CompanyFormData
|
||||||
|
): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
if (!data.name || data.name.trim() === "") {
|
||||||
|
errors.push({ field: "name", message: "Firmenname erforderlich" });
|
||||||
|
} else if (data.name.length > 255) {
|
||||||
|
errors.push({ field: "name", message: "Firmenname darf maximal 255 Zeichen sein" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.address || data.address.trim() === "") {
|
||||||
|
errors.push({ field: "address", message: "Adresse erforderlich" });
|
||||||
|
} else if (data.address.length > 500) {
|
||||||
|
errors.push({ field: "address", message: "Adresse darf maximal 500 Zeichen sein" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.zip || data.zip.trim() === "") {
|
||||||
|
errors.push({ field: "zip", message: "PLZ erforderlich" });
|
||||||
|
} else if (!/^[\d\s-]*$/.test(data.zip)) {
|
||||||
|
errors.push({ field: "zip", message: "PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten" });
|
||||||
|
} else if (data.zip.length > 20) {
|
||||||
|
errors.push({ field: "zip", message: "PLZ darf maximal 20 Zeichen sein" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.city || data.city.trim() === "") {
|
||||||
|
errors.push({ field: "city", message: "Stadt erforderlich" });
|
||||||
|
} else if (data.city.length > 100) {
|
||||||
|
errors.push({ field: "city", message: "Stadt darf maximal 100 Zeichen sein" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional fields with validation
|
||||||
|
if (data.taxId) {
|
||||||
|
const taxError = validateTaxId(data.taxId);
|
||||||
|
if (taxError) errors.push(taxError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.vatId) {
|
||||||
|
const vatError = validateVatId(data.vatId);
|
||||||
|
if (vatError) errors.push(vatError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.website) {
|
||||||
|
const webError = validateWebsite(data.website);
|
||||||
|
if (webError) errors.push(webError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.bankIban) {
|
||||||
|
const ibanError = validateIban(data.bankIban);
|
||||||
|
if (ibanError) errors.push(ibanError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.bankBic) {
|
||||||
|
const bicError = validateBic(data.bankBic);
|
||||||
|
if (bicError) errors.push(bicError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.email && data.email.trim()) {
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||||
|
errors.push({ field: "email", message: "Ungültige E-Mail-Adresse" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.phone && data.phone.length > 20) {
|
||||||
|
errors.push({ field: "phone", message: "Telefonnummer darf maximal 20 Zeichen sein" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.legalForm && data.legalForm.length > 100) {
|
||||||
|
errors.push({ field: "legalForm", message: "Rechtsform darf maximal 100 Zeichen sein" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.country && data.country.length > 2) {
|
||||||
|
errors.push({ field: "country", message: "Ländercode darf maximal 2 Zeichen sein" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.bankName && data.bankName.length > 255) {
|
||||||
|
errors.push({ field: "bankName", message: "Bankname darf maximal 255 Zeichen sein" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
if (errors.length > 0) {
|
||||||
|
debugLog("warning", `Validation failed: ${errors.length} error(s)`, errors);
|
||||||
|
} else {
|
||||||
|
debugLog("success", "Validation passed", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate API response errors
|
||||||
|
*/
|
||||||
|
export function handleApiError(error: unknown, endpoint: string): void {
|
||||||
|
debugLog("error", `API Error from ${endpoint}`, error);
|
||||||
|
|
||||||
|
if (error instanceof Response) {
|
||||||
|
debugLog("error", `HTTP ${error.status}: ${error.statusText}`, error);
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
debugLog("error", error.message, error.stack);
|
||||||
|
} else {
|
||||||
|
debugLog("error", "Unknown error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get error message for a specific field
|
||||||
|
*/
|
||||||
|
export function getFieldError(field: string, errors: ValidationError[]): string | null {
|
||||||
|
const error = errors.find((e) => e.field === field);
|
||||||
|
return error?.message || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if field has error
|
||||||
|
*/
|
||||||
|
export function hasFieldError(field: string, errors: ValidationError[]): boolean {
|
||||||
|
return errors.some((e) => e.field === field);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import prisma from "./prisma.server";
|
||||||
|
|
||||||
|
let initStarted = false;
|
||||||
|
let initCompleted = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run Prisma migrations to bring database schema up to date
|
||||||
|
*/
|
||||||
|
async function runMigrations(): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log("[DB Init] Running Prisma migrations...");
|
||||||
|
execSync("npx prisma migrate deploy", {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
console.log("[DB Init] ✓ Migrations completed successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DB Init] ✗ Migration failed:", error);
|
||||||
|
throw new Error("Database migration failed. See logs above.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize database: run all pending migrations
|
||||||
|
* Safe to call multiple times (idempotent)
|
||||||
|
*/
|
||||||
|
export async function initializeDatabase(): Promise<void> {
|
||||||
|
// Prevent concurrent initialization attempts
|
||||||
|
if (initCompleted) return;
|
||||||
|
if (initStarted) {
|
||||||
|
// Wait for initialization to complete
|
||||||
|
while (!initCompleted) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initStarted = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runMigrations();
|
||||||
|
console.log("[DB Init] ✓ Database initialization complete");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[DB Init] Fatal error during database initialization:", error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
initCompleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check database health (non-blocking)
|
||||||
|
*/
|
||||||
|
export async function checkDatabaseHealth(): Promise<{
|
||||||
|
connected: boolean;
|
||||||
|
isEmpty: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const userCount = await prisma.user.count();
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
isEmpty: userCount === 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
isEmpty: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Comprehensive server-side error logging for debugging
|
||||||
|
* Captures errors with full context: stack traces, request details, environment
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ErrorContext {
|
||||||
|
request?: Request;
|
||||||
|
userId?: string | null;
|
||||||
|
route?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorLogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
stack?: string;
|
||||||
|
context?: {
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
route?: string;
|
||||||
|
userId?: string | null;
|
||||||
|
ipAddress?: string | null;
|
||||||
|
userAgent?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
environment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error message and stack from any error type
|
||||||
|
*/
|
||||||
|
function extractErrorInfo(error: unknown): { message: string; stack?: string } {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return { message: error };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const err = error as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
message: (err.message as string) || String(error),
|
||||||
|
stack: (err.stack as string) || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract request context
|
||||||
|
*/
|
||||||
|
function extractRequestContext(
|
||||||
|
request?: Request
|
||||||
|
): ErrorLogEntry["context"] {
|
||||||
|
if (!request) return {};
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const ipAddress =
|
||||||
|
request.headers.get("x-forwarded-for") ??
|
||||||
|
request.headers.get("x-real-ip") ??
|
||||||
|
null;
|
||||||
|
const userAgent = request.headers.get("user-agent");
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: request.method,
|
||||||
|
url: url.pathname + url.search,
|
||||||
|
ipAddress,
|
||||||
|
userAgent: userAgent ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build comprehensive error log entry
|
||||||
|
*/
|
||||||
|
function buildErrorLogEntry(
|
||||||
|
type: string,
|
||||||
|
error: unknown,
|
||||||
|
context?: ErrorContext
|
||||||
|
): ErrorLogEntry {
|
||||||
|
const { message, stack } = extractErrorInfo(error);
|
||||||
|
const requestContext = extractRequestContext(context?.request);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
type,
|
||||||
|
message,
|
||||||
|
stack,
|
||||||
|
context: {
|
||||||
|
...requestContext,
|
||||||
|
route: context?.route,
|
||||||
|
userId: context?.userId,
|
||||||
|
metadata: context?.metadata,
|
||||||
|
},
|
||||||
|
environment: process.env.NODE_ENV || "development",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format error log for console output
|
||||||
|
*/
|
||||||
|
function formatErrorLog(entry: ErrorLogEntry): string {
|
||||||
|
const parts = [
|
||||||
|
`[${entry.type}]`,
|
||||||
|
`${entry.timestamp}`,
|
||||||
|
entry.message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (entry.context?.route) parts.push(`route: ${entry.context.route}`);
|
||||||
|
if (entry.context?.method && entry.context?.url) {
|
||||||
|
parts.push(`${entry.context.method} ${entry.context.url}`);
|
||||||
|
}
|
||||||
|
if (entry.context?.userId) parts.push(`user: ${entry.context.userId}`);
|
||||||
|
if (entry.context?.ipAddress) parts.push(`ip: ${entry.context.ipAddress}`);
|
||||||
|
|
||||||
|
let output = parts.join(" | ");
|
||||||
|
|
||||||
|
if (entry.stack) {
|
||||||
|
output += "\n" + entry.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.context?.metadata) {
|
||||||
|
output += "\nMetadata: " + JSON.stringify(entry.context.metadata, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a route/loader error
|
||||||
|
*/
|
||||||
|
export function logRouteError(
|
||||||
|
error: unknown,
|
||||||
|
context: ErrorContext & { route: string }
|
||||||
|
): void {
|
||||||
|
const entry = buildErrorLogEntry("ROUTE_ERROR", error, context);
|
||||||
|
console.error("\n" + "=".repeat(80));
|
||||||
|
console.error(formatErrorLog(entry));
|
||||||
|
console.error("=".repeat(80) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an action/mutation error
|
||||||
|
*/
|
||||||
|
export function logActionError(
|
||||||
|
error: unknown,
|
||||||
|
context: ErrorContext & { action: string }
|
||||||
|
): void {
|
||||||
|
const entry = buildErrorLogEntry("ACTION_ERROR", error, {
|
||||||
|
...context,
|
||||||
|
metadata: {
|
||||||
|
action: context.action,
|
||||||
|
...context.metadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.error("\n" + "=".repeat(80));
|
||||||
|
console.error(formatErrorLog(entry));
|
||||||
|
console.error("=".repeat(80) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a database error
|
||||||
|
*/
|
||||||
|
export function logDatabaseError(
|
||||||
|
error: unknown,
|
||||||
|
operation: string,
|
||||||
|
context?: Omit<ErrorContext, "metadata">
|
||||||
|
): void {
|
||||||
|
const entry = buildErrorLogEntry("DATABASE_ERROR", error, {
|
||||||
|
...context,
|
||||||
|
metadata: { operation },
|
||||||
|
});
|
||||||
|
console.error("\n" + "=".repeat(80));
|
||||||
|
console.error(formatErrorLog(entry));
|
||||||
|
console.error("=".repeat(80) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an API error
|
||||||
|
*/
|
||||||
|
export function logApiError(
|
||||||
|
error: unknown,
|
||||||
|
context: ErrorContext & { endpoint: string; statusCode?: number }
|
||||||
|
): void {
|
||||||
|
const entry = buildErrorLogEntry("API_ERROR", error, {
|
||||||
|
...context,
|
||||||
|
metadata: {
|
||||||
|
endpoint: context.endpoint,
|
||||||
|
statusCode: context.statusCode || 500,
|
||||||
|
...context.metadata,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.error("\n" + "=".repeat(80));
|
||||||
|
console.error(formatErrorLog(entry));
|
||||||
|
console.error("=".repeat(80) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a server startup error
|
||||||
|
*/
|
||||||
|
export function logStartupError(error: unknown): void {
|
||||||
|
const entry = buildErrorLogEntry("STARTUP_ERROR", error);
|
||||||
|
console.error("\n" + "=".repeat(80));
|
||||||
|
console.error("🚨 CRITICAL: Server failed to start");
|
||||||
|
console.error(formatErrorLog(entry));
|
||||||
|
console.error("=".repeat(80) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a generic error with type
|
||||||
|
*/
|
||||||
|
export function logError(
|
||||||
|
type: string,
|
||||||
|
error: unknown,
|
||||||
|
context?: ErrorContext
|
||||||
|
): void {
|
||||||
|
const entry = buildErrorLogEntry(type, error, context);
|
||||||
|
console.error("\n" + "=".repeat(80));
|
||||||
|
console.error(formatErrorLog(entry));
|
||||||
|
console.error("=".repeat(80) + "\n");
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ export type LogAction =
|
|||||||
| "DELETE_CUSTOMER"
|
| "DELETE_CUSTOMER"
|
||||||
| "CREATE_SERVICE"
|
| "CREATE_SERVICE"
|
||||||
| "UPDATE_SERVICE"
|
| "UPDATE_SERVICE"
|
||||||
| "DELETE_SERVICE";
|
| "DELETE_SERVICE"
|
||||||
|
| "UPLOAD_BELEG"
|
||||||
|
| "DELETE_BELEG";
|
||||||
|
|
||||||
export async function log({
|
export async function log({
|
||||||
userId,
|
userId,
|
||||||
@@ -45,9 +47,21 @@ export async function log({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Validate that userId exists in the database if provided
|
||||||
|
let validatedUserId = userId;
|
||||||
|
if (userId) {
|
||||||
|
const userExists = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!userExists) {
|
||||||
|
validatedUserId = null; // User doesn't exist, log as anonymous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: userId ?? null,
|
userId: validatedUserId ?? null,
|
||||||
action,
|
action,
|
||||||
entity: entity ?? null,
|
entity: entity ?? null,
|
||||||
entityId: entityId ?? null,
|
entityId: entityId ?? null,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { RateLimiterMemory } from "rate-limiter-flexible";
|
import { RateLimiterMemory } from "rate-limiter-flexible";
|
||||||
|
|
||||||
// Max. 5 Loginversuche pro IP innerhalb von 15 Minuten
|
// Max. 15 Loginversuche pro IP innerhalb von 3 Minuten
|
||||||
const loginLimiter = new RateLimiterMemory({
|
const loginLimiter = new RateLimiterMemory({
|
||||||
points: 5,
|
points: 15,
|
||||||
duration: 60 * 15,
|
duration: 60 * 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function checkLoginRateLimit(request: Request): Promise<string | null> {
|
export async function checkLoginRateLimit(request: Request): Promise<string | null> {
|
||||||
|
|||||||
+24
-10
@@ -35,7 +35,7 @@ export const taxRateSchema = z
|
|||||||
export const ibanSchema = z
|
export const ibanSchema = z
|
||||||
.string()
|
.string()
|
||||||
.refine(
|
.refine(
|
||||||
(iban) => /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(iban),
|
(iban) => iban === "" || /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(iban),
|
||||||
"Ungültige IBAN"
|
"Ungültige IBAN"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -44,14 +44,20 @@ export const ibanSchema = z
|
|||||||
*/
|
*/
|
||||||
export const taxIdSchema = z
|
export const taxIdSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^\d{10}$/, "Steuernummer muss 10 Ziffern haben");
|
.refine(
|
||||||
|
(val) => val === "" || /^\d{10}$/.test(val),
|
||||||
|
"Steuernummer muss 10 Ziffern haben"
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VAT ID (Umsatzsteuer-IdNr): DE + 9 digits
|
* VAT ID (Umsatzsteuer-IdNr): DE + 9 digits
|
||||||
*/
|
*/
|
||||||
export const vatIdSchema = z
|
export const vatIdSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^DE\d{9}$/, "USt-IdNr. muss im Format DE + 9 Ziffern sein");
|
.refine(
|
||||||
|
(val) => val === "" || /^DE\d{9}$/.test(val),
|
||||||
|
"USt-IdNr. muss im Format DE + 9 Ziffern sein"
|
||||||
|
);
|
||||||
|
|
||||||
// ===== Invoice Schemas =====
|
// ===== Invoice Schemas =====
|
||||||
|
|
||||||
@@ -131,8 +137,8 @@ export const companySchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.max(100, "Rechtsform darf maximal 100 Zeichen sein")
|
.max(100, "Rechtsform darf maximal 100 Zeichen sein")
|
||||||
.optional(),
|
.optional(),
|
||||||
taxId: taxIdSchema.optional(),
|
taxId: taxIdSchema.nullable(),
|
||||||
vatId: vatIdSchema.optional(),
|
vatId: vatIdSchema.nullable(),
|
||||||
address: z
|
address: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, "Adresse erforderlich")
|
.min(1, "Adresse erforderlich")
|
||||||
@@ -162,14 +168,22 @@ export const companySchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
website: z
|
website: z
|
||||||
.string()
|
.string()
|
||||||
.url("Ungültige URL")
|
.refine(
|
||||||
|
(val) => val === "" || /^https?:\/\//.test(val),
|
||||||
|
"Website muss mit http:// oder https:// beginnen"
|
||||||
|
)
|
||||||
.max(255, "Website darf maximal 255 Zeichen sein")
|
.max(255, "Website darf maximal 255 Zeichen sein")
|
||||||
.optional(),
|
.optional()
|
||||||
bankIban: ibanSchema.optional(),
|
.or(z.literal("")),
|
||||||
|
bankIban: ibanSchema.nullable(),
|
||||||
bankBic: z
|
bankBic: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^[A-Z0-9]{8,11}$/, "Ungültiger BIC")
|
.refine(
|
||||||
.optional(),
|
(val) => val === "" || /^[A-Z0-9]{8,11}$/.test(val),
|
||||||
|
"Ungültiger BIC"
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("")),
|
||||||
bankName: z
|
bankName: z
|
||||||
.string()
|
.string()
|
||||||
.max(255, "Bankname darf maximal 255 Zeichen sein")
|
.max(255, "Bankname darf maximal 255 Zeichen sein")
|
||||||
|
|||||||
+51
-6
@@ -1,21 +1,61 @@
|
|||||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from "react-router";
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from "react-router";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
import { DebugPanel } from "./components/debug-panel";
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary() {
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
const message = isRouteErrorResponse(error)
|
|
||||||
|
// Get error details
|
||||||
|
const isResponse = isRouteErrorResponse(error);
|
||||||
|
const status = isResponse ? error.status : 500;
|
||||||
|
const statusText = isResponse ? error.statusText : "Internal Server Error";
|
||||||
|
const message = isResponse
|
||||||
? `${error.status} ${error.statusText}`
|
? `${error.status} ${error.statusText}`
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: String(error);
|
: String(error);
|
||||||
const stack = error instanceof Error ? error.stack : undefined;
|
const stack = error instanceof Error ? error.stack : undefined;
|
||||||
|
|
||||||
|
// Log error details for debugging
|
||||||
|
if (typeof console !== "undefined") {
|
||||||
|
console.error("\n" + "=".repeat(80));
|
||||||
|
console.error("[ERROR_BOUNDARY]", new Date().toISOString());
|
||||||
|
console.error(`Status: ${status} ${statusText}`);
|
||||||
|
console.error(`Message: ${message}`);
|
||||||
|
if (stack) console.error("Stack:\n" + stack);
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
console.error("Full Error Object:", error);
|
||||||
|
}
|
||||||
|
console.error("=".repeat(80) + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head><meta charSet="utf-8" /><Meta /><Links /></head>
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
</head>
|
||||||
<body style={{ fontFamily: "monospace", padding: "2rem", background: "#fff1f2", color: "#9f1239" }}>
|
<body style={{ fontFamily: "monospace", padding: "2rem", background: "#fff1f2", color: "#9f1239" }}>
|
||||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>Fehler</h1>
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1rem" }}>
|
||||||
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", background: "#ffe4e6", padding: "1rem", borderRadius: "0.5rem" }}>{message}</pre>
|
{status} Fehler
|
||||||
{import.meta.env.DEV && stack && <pre style={{ marginTop: "1rem", fontSize: "0.75rem", color: "#64748b", whiteSpace: "pre-wrap" }}>{stack}</pre>}
|
</h1>
|
||||||
|
<p style={{ marginBottom: "1rem", fontSize: "0.9rem" }}>
|
||||||
|
{statusText}
|
||||||
|
</p>
|
||||||
|
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word", background: "#ffe4e6", padding: "1rem", borderRadius: "0.5rem" }}>
|
||||||
|
{message}
|
||||||
|
</pre>
|
||||||
|
{import.meta.env.DEV && stack && (
|
||||||
|
<details style={{ marginTop: "1rem" }}>
|
||||||
|
<summary style={{ cursor: "pointer", fontWeight: 600, color: "#64748b" }}>
|
||||||
|
Stack Trace (Dev Only)
|
||||||
|
</summary>
|
||||||
|
<pre style={{ marginTop: "0.5rem", fontSize: "0.75rem", color: "#64748b", whiteSpace: "pre-wrap", background: "#f1f5f9", padding: "1rem", borderRadius: "0.5rem" }}>
|
||||||
|
{stack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -45,5 +85,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <Outlet />;
|
return (
|
||||||
|
<>
|
||||||
|
<Outlet />
|
||||||
|
<DebugPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default [
|
|||||||
route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"),
|
route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"),
|
||||||
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
|
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
|
||||||
route("api/companies/:id/money", "routes/api.companies.$id.money.ts"),
|
route("api/companies/:id/money", "routes/api.companies.$id.money.ts"),
|
||||||
|
route("api/admin/companies/:id/delete", "routes/api.admin.companies.$id.delete.ts"),
|
||||||
route("api/customers", "routes/api.customers.ts"),
|
route("api/customers", "routes/api.customers.ts"),
|
||||||
route("api/customers/:id", "routes/api.customers.$id.ts"),
|
route("api/customers/:id", "routes/api.customers.$id.ts"),
|
||||||
route("api/services", "routes/api.services.ts"),
|
route("api/services", "routes/api.services.ts"),
|
||||||
@@ -59,6 +60,8 @@ export default [
|
|||||||
route("api/ausgaben/:id", "routes/api.ausgaben.$id.ts"),
|
route("api/ausgaben/:id", "routes/api.ausgaben.$id.ts"),
|
||||||
route("api/einnahmen", "routes/api.einnahmen.ts"),
|
route("api/einnahmen", "routes/api.einnahmen.ts"),
|
||||||
route("api/einnahmen/:id", "routes/api.einnahmen.$id.ts"),
|
route("api/einnahmen/:id", "routes/api.einnahmen.$id.ts"),
|
||||||
|
route("api/einnahmen/:id/upload", "routes/api.einnahmen.$id.upload.ts"),
|
||||||
|
route("api/beleg/:userId/:filename", "routes/api.beleg.$userId.$filename.ts"),
|
||||||
route("api/companies/:id/buchungkategorien", "routes/api.companies.$id.buchungkategorien.ts"),
|
route("api/companies/:id/buchungkategorien", "routes/api.companies.$id.buchungkategorien.ts"),
|
||||||
route("api/companies/:id/buchungkategorien/:katId", "routes/api.companies.$id.buchungkategorien.$katId.ts"),
|
route("api/companies/:id/buchungkategorien/:katId", "routes/api.companies.$id.buchungkategorien.$katId.ts"),
|
||||||
route("api/anlagevermoegen", "routes/api.anlagevermoegen.ts"),
|
route("api/anlagevermoegen", "routes/api.anlagevermoegen.ts"),
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Link, useLoaderData } from "react-router";
|
|||||||
import { requireAdmin } from "@/session.server";
|
import { requireAdmin } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Building2, Archive } from "lucide-react";
|
import { Building2, Archive, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export async function loader({ request }: { request: Request }) {
|
export async function loader({ request }: { request: Request }) {
|
||||||
await requireAdmin(request);
|
await requireAdmin(request);
|
||||||
@@ -68,6 +69,37 @@ function MandantenTabelle({
|
|||||||
title: string;
|
title: string;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleDelete = async (companyId: string, companyName: string) => {
|
||||||
|
if (deleteConfirm !== companyId) {
|
||||||
|
setDeleteConfirm(companyId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/companies/${companyId}/delete`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload the page to refresh the list
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Fehler beim Löschen: ${error.error || response.statusText}`);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Fehler beim Löschen: ${error}`);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (companies.length === 0) return null;
|
if (companies.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -113,13 +145,25 @@ function MandantenTabelle({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right text-slate-600">{company._count.invoices}</td>
|
<td className="px-4 py-3 text-right text-slate-600">{company._count.invoices}</td>
|
||||||
<td className="px-4 py-3 text-right text-slate-600">{company._count.customers}</td>
|
<td className="px-4 py-3 text-right text-slate-600">{company._count.customers}</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right space-x-2 flex justify-end">
|
||||||
<Link
|
<Link
|
||||||
to={`/companies/${company.id}`}
|
to={`/companies/${company.id}`}
|
||||||
className="text-indigo-600 hover:text-indigo-800 font-medium text-xs"
|
className="text-indigo-600 hover:text-indigo-800 font-medium text-xs"
|
||||||
>
|
>
|
||||||
Öffnen →
|
Öffnen →
|
||||||
</Link>
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(company.id, company.name)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className={`text-xs font-medium flex items-center gap-1 px-2 py-1 rounded transition-colors ${
|
||||||
|
deleteConfirm === company.id
|
||||||
|
? "bg-red-100 text-red-700 hover:bg-red-200"
|
||||||
|
: "text-slate-500 hover:text-red-600"
|
||||||
|
} ${isDeleting ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{deleteConfirm === company.id ? "Bestätigen?" : "Löschen"}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { requireAdmin } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
import { log } from "@/lib/logger.server";
|
||||||
|
|
||||||
|
export async function action({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
|
const user = await requireAdmin(request);
|
||||||
|
|
||||||
|
if (request.method !== "DELETE") {
|
||||||
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await prisma.company.findUnique({
|
||||||
|
where: { id: params.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
return Response.json({ error: "Company not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.company.delete({
|
||||||
|
where: { id: params.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
await log({
|
||||||
|
userId: user.id,
|
||||||
|
action: "DELETE_COMPANY",
|
||||||
|
entity: "Company",
|
||||||
|
entityId: params.id,
|
||||||
|
metadata: { companyName: company.name },
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join, resolve, extname } from "node:path";
|
||||||
|
import { requireUser } from "@/session.server";
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
};
|
||||||
|
|
||||||
|
function storageRoot(): string {
|
||||||
|
return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader({
|
||||||
|
request,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
request: Request;
|
||||||
|
params: { userId: string; filename: string };
|
||||||
|
}) {
|
||||||
|
const user = await requireUser(request);
|
||||||
|
|
||||||
|
// Users may only access their own documents
|
||||||
|
if (params.userId !== user.id) {
|
||||||
|
throw new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent path traversal
|
||||||
|
const root = storageRoot();
|
||||||
|
const filePath = join(root, params.userId, params.filename);
|
||||||
|
if (!filePath.startsWith(root)) {
|
||||||
|
throw new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Buffer;
|
||||||
|
try {
|
||||||
|
data = await readFile(filePath);
|
||||||
|
} catch {
|
||||||
|
throw new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extname(params.filename).toLowerCase();
|
||||||
|
const contentType = MIME[ext] ?? "application/octet-stream";
|
||||||
|
const disposition = contentType === "application/pdf" ? "inline" : "inline";
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(data), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Disposition": `${disposition}; filename="${params.filename}"`,
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { getApiUser } from "@/session.server";
|
import { getApiUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { log } from "@/lib/logger.server";
|
import { log } from "@/lib/logger.server";
|
||||||
|
import { logApiError } from "@/lib/error-logger.server";
|
||||||
import { companySchema } from "@/lib/schemas";
|
import { companySchema } from "@/lib/schemas";
|
||||||
|
|
||||||
export async function loader({ request }: { request: Request }) {
|
export async function loader({ request }: { request: Request }) {
|
||||||
|
try {
|
||||||
const user = await getApiUser(request);
|
const user = await getApiUser(request);
|
||||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
@@ -14,15 +16,25 @@ export async function loader({ request }: { request: Request }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(companies);
|
return Response.json(companies);
|
||||||
|
} catch (error) {
|
||||||
|
logApiError(error, {
|
||||||
|
request,
|
||||||
|
endpoint: "/api/companies",
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: { request: Request }) {
|
export async function action({ request }: { request: Request }) {
|
||||||
|
try {
|
||||||
const user = await getApiUser(request);
|
const user = await getApiUser(request);
|
||||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const parsed = companySchema.safeParse(body);
|
const parsed = companySchema.safeParse(body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
console.warn("[CompanyAPI] Validation failed:", parsed.error.issues);
|
||||||
return Response.json({ error: parsed.error.issues }, { status: 400 });
|
return Response.json({ error: parsed.error.issues }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,4 +45,12 @@ export async function action({ request }: { request: Request }) {
|
|||||||
await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request });
|
await log({ userId: user.id, action: "CREATE_COMPANY", entity: "Company", entityId: company.id, request });
|
||||||
|
|
||||||
return Response.json(company, { status: 201 });
|
return Response.json(company, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
logApiError(error, {
|
||||||
|
request,
|
||||||
|
endpoint: "/api/companies",
|
||||||
|
statusCode: 500,
|
||||||
|
});
|
||||||
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const updateSchema = z.object({
|
|||||||
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
datum: z.string().min(1),
|
datum: z.string().min(1),
|
||||||
beschreibung: z.string().optional(),
|
beschreibung: z.string().optional(),
|
||||||
|
belegUrl: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +49,7 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
|
account: parsed.data.zahlungsart === "KASSE" ? "KASSE" : "BANK",
|
||||||
date: new Date(parsed.data.datum),
|
date: new Date(parsed.data.datum),
|
||||||
description: parsed.data.beschreibung,
|
description: parsed.data.beschreibung,
|
||||||
|
belegUrl: parsed.data.belegUrl || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { getApiUser } from "@/session.server";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
import { log } from "@/lib/logger.server";
|
||||||
|
import { writeFile, mkdir, unlink } from "node:fs/promises";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
|
const ALLOWED_MIME: Record<string, string> = {
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/webp": "webp",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Absolute path to the document storage root (configurable via env). */
|
||||||
|
function storageRoot(): string {
|
||||||
|
return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** belegUrl format stored in DB: "beleg:{userId}/{filename}" */
|
||||||
|
function parseBelegPath(belegUrl: string | null): string | null {
|
||||||
|
if (!belegUrl?.startsWith("beleg:")) return null;
|
||||||
|
return belegUrl.slice("beleg:".length); // "{userId}/{filename}"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUploadedFile(belegUrl: string | null) {
|
||||||
|
const rel = parseBelegPath(belegUrl);
|
||||||
|
if (!rel) return;
|
||||||
|
try {
|
||||||
|
await unlink(join(storageRoot(), rel));
|
||||||
|
} catch {
|
||||||
|
// File may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({
|
||||||
|
request,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
request: Request;
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const user = await getApiUser(request);
|
||||||
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const buchung = await prisma.buchung.findFirst({
|
||||||
|
where: { id: params.id, company: { userId: user.id }, isBusinessRecord: true },
|
||||||
|
select: { id: true, companyId: true, belegUrl: true },
|
||||||
|
});
|
||||||
|
if (!buchung) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
// DELETE — remove beleg
|
||||||
|
if (request.method === "DELETE") {
|
||||||
|
await removeUploadedFile(buchung.belegUrl);
|
||||||
|
await prisma.buchung.update({ where: { id: params.id }, data: { belegUrl: null } });
|
||||||
|
await log({
|
||||||
|
userId: user.id,
|
||||||
|
action: "DELETE_BELEG",
|
||||||
|
entity: "Buchung",
|
||||||
|
entityId: params.id,
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST — upload new beleg
|
||||||
|
let formData: FormData;
|
||||||
|
try {
|
||||||
|
formData = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "Ungültige Formulardaten" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return Response.json({ error: "Keine Datei angegeben" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_SIZE_BYTES) {
|
||||||
|
return Response.json({ error: "Datei zu groß (max. 10 MB)" }, { status: 413 });
|
||||||
|
}
|
||||||
|
const ext = ALLOWED_MIME[file.type];
|
||||||
|
if (!ext) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Dateityp nicht erlaubt (erlaubt: PDF, JPG, PNG, WebP, GIF)" },
|
||||||
|
{ status: 415 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace old file if present
|
||||||
|
await removeUploadedFile(buchung.belegUrl);
|
||||||
|
|
||||||
|
const safeName = `${buchung.id}-${Date.now()}.${ext}`;
|
||||||
|
const userDir = join(storageRoot(), user.id);
|
||||||
|
await mkdir(userDir, { recursive: true });
|
||||||
|
await writeFile(join(userDir, safeName), Buffer.from(await file.arrayBuffer()));
|
||||||
|
|
||||||
|
// Store as "beleg:{userId}/{storedName}|{originalName}"
|
||||||
|
// storedName is used for serving; originalName is preserved for display
|
||||||
|
const originalName = file.name;
|
||||||
|
const belegUrl = `beleg:${user.id}/${safeName}|${originalName}`;
|
||||||
|
await prisma.buchung.update({ where: { id: params.id }, data: { belegUrl } });
|
||||||
|
|
||||||
|
await log({
|
||||||
|
userId: user.id,
|
||||||
|
action: "UPLOAD_BELEG",
|
||||||
|
entity: "Buchung",
|
||||||
|
entityId: params.id,
|
||||||
|
metadata: { filename: file.name, size: file.size, type: file.type },
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return Response.json({ belegUrl, originalName: file.name, size: file.size });
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ const createSchema = z.object({
|
|||||||
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
zahlungsart: z.enum(["KASSE", "BANK"]).default("BANK"),
|
||||||
datum: z.string().min(1),
|
datum: z.string().min(1),
|
||||||
beschreibung: z.string().optional(),
|
beschreibung: z.string().optional(),
|
||||||
|
belegUrl: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,6 +106,7 @@ export async function action({ request }: { request: Request }) {
|
|||||||
steuersatz: parsed.data.steuersatz,
|
steuersatz: parsed.data.steuersatz,
|
||||||
zahlungsart: parsed.data.zahlungsart,
|
zahlungsart: parsed.data.zahlungsart,
|
||||||
isBusinessRecord: true,
|
isBusinessRecord: true,
|
||||||
|
belegUrl: parsed.data.belegUrl || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { calcItemAmounts, calcInvoiceTotals, calcItemAmountsKleinunternehmer } f
|
|||||||
import { log } from "@/lib/logger.server";
|
import { log } from "@/lib/logger.server";
|
||||||
import { InvoiceStatus } from "@prisma/client";
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
import { invoiceUpdateSchema, invoiceStatusSchema, invoiceItemSchema } from "@/lib/schemas";
|
import { invoiceUpdateSchema, invoiceStatusSchema, invoiceItemSchema } from "@/lib/schemas";
|
||||||
|
import { writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
async function getInvoice(id: string, userId: string) {
|
async function getInvoice(id: string, userId: string) {
|
||||||
return prisma.invoice.findFirst({
|
return prisma.invoice.findFirst({
|
||||||
@@ -13,6 +15,39 @@ async function getInvoice(id: string, userId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Storage root for documents */
|
||||||
|
function storageRoot(): string {
|
||||||
|
return resolve(process.env.BELEG_STORAGE_PATH ?? "data/documents");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate and save invoice PDF as beleg (receipt) */
|
||||||
|
async function generateAndSaveInvoicePDF(invoice: Awaited<ReturnType<typeof getInvoice>>, userId: string): Promise<string | null> {
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { renderToBuffer } = await import("@react-pdf/renderer");
|
||||||
|
const React = (await import("react")).default;
|
||||||
|
const { InvoicePDFDocument } = await import("@/components/invoice/invoice-pdf");
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const element = React.createElement(InvoicePDFDocument as any, { invoice }) as any;
|
||||||
|
const buffer = await renderToBuffer(element);
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
const safeName = `${invoice.id}-${Date.now()}.pdf`;
|
||||||
|
const userDir = join(storageRoot(), userId);
|
||||||
|
await mkdir(userDir, { recursive: true });
|
||||||
|
await writeFile(join(userDir, safeName), Buffer.from(buffer));
|
||||||
|
|
||||||
|
// Return as "beleg:{userId}/{storedName}|{originalName}"
|
||||||
|
const originalName = `rechnung-${invoice.number ?? invoice.id}.pdf`;
|
||||||
|
return `beleg:${userId}/${safeName}|${originalName}`;
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to generate invoice PDF");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
const user = await getApiUser(request);
|
const user = await getApiUser(request);
|
||||||
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -148,6 +183,16 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
|
|
||||||
// Handle Buchung sync: Create when PAID, delete when unpaying
|
// Handle Buchung sync: Create when PAID, delete when unpaying
|
||||||
if (newStatus === "PAID" && oldStatus !== "PAID") {
|
if (newStatus === "PAID" && oldStatus !== "PAID") {
|
||||||
|
// Generate and save invoice PDF as beleg
|
||||||
|
const belegUrl = await generateAndSaveInvoicePDF(invoice, user.id);
|
||||||
|
|
||||||
|
// Calculate weighted average tax rate (kann mehrere Items mit unterschiedlichen Steuersätzen geben)
|
||||||
|
let averageTaxRate = 0;
|
||||||
|
if (invoice.taxTotal > 0 && invoice.netTotal > 0) {
|
||||||
|
// steuersatz = (taxTotal / netTotal) * 100
|
||||||
|
averageTaxRate = Math.round((invoice.taxTotal / invoice.netTotal) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
// Create a Buchung for the invoice payment
|
// Create a Buchung for the invoice payment
|
||||||
const buchung = await prisma.buchung.create({
|
const buchung = await prisma.buchung.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -159,6 +204,8 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
description: `Rechnung ${invoice.number}`,
|
description: `Rechnung ${invoice.number}`,
|
||||||
kategorie: "Rechnungseinnahme",
|
kategorie: "Rechnungseinnahme",
|
||||||
isBusinessRecord: true,
|
isBusinessRecord: true,
|
||||||
|
steuersatz: invoice.kleinunternehmer ? 0 : averageTaxRate, // 0 for Kleinunternehmer
|
||||||
|
belegUrl: belegUrl, // Attach the generated invoice PDF
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,7 +226,7 @@ export async function action({ request, params }: { request: Request; params: {
|
|||||||
action: "UPDATE_INVOICE_STATUS",
|
action: "UPDATE_INVOICE_STATUS",
|
||||||
entity: "Invoice",
|
entity: "Invoice",
|
||||||
entityId: params.id,
|
entityId: params.id,
|
||||||
metadata: { oldStatus, newStatus, buchungId: buchung.id },
|
metadata: { oldStatus, newStatus, buchungId: buchung.id, belegUrl, steuersatz: averageTaxRate },
|
||||||
request,
|
request,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useRef, useCallback } from "react";
|
||||||
import { Link, useLoaderData, useRevalidator } from "react-router";
|
import { Link, useLoaderData, useRevalidator } from "react-router";
|
||||||
import { requireUser } from "@/session.server";
|
import { requireUser } from "@/session.server";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
@@ -6,10 +6,32 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { ChevronLeft, Plus, Pencil, Trash2, Loader2, Banknote, Landmark } from "lucide-react";
|
import { ChevronLeft, Plus, Pencil, Trash2, Loader2, Banknote, Landmark, List, LayoutGrid, Paperclip, Upload, X, FileText } from "lucide-react";
|
||||||
import { formatCurrency } from "@/lib/tax";
|
import { formatCurrency } from "@/lib/tax";
|
||||||
import { DEFAULT_EINNAHME_KATEGORIEN } from "@/lib/kategorie-defaults";
|
import { DEFAULT_EINNAHME_KATEGORIEN } from "@/lib/kategorie-defaults";
|
||||||
|
|
||||||
|
/** Converts stored belegUrl ("beleg:{userId}/{storedName}|{originalName}") to a viewable href. */
|
||||||
|
function belegHref(belegUrl: string | null): string | null {
|
||||||
|
if (!belegUrl) return null;
|
||||||
|
if (belegUrl.startsWith("beleg:")) {
|
||||||
|
const rel = belegUrl.slice("beleg:".length); // "userId/storedName|originalName"
|
||||||
|
const [userId, rest] = rel.split("/");
|
||||||
|
const storedName = rest?.split("|")[0]; // strip "|originalName" if present
|
||||||
|
return `/api/beleg/${userId}/${storedName}`;
|
||||||
|
}
|
||||||
|
return belegUrl; // fallback for legacy http(s) URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts a human-readable display name from a stored belegUrl. */
|
||||||
|
function belegDisplayName(belegUrl: string): string {
|
||||||
|
if (belegUrl.startsWith("beleg:")) {
|
||||||
|
const rest = belegUrl.split("/").pop() ?? "Beleg"; // "storedName|originalName" or just "storedName"
|
||||||
|
const parts = rest.split("|");
|
||||||
|
return parts.length > 1 ? parts.slice(1).join("|") : parts[0]; // prefer originalName
|
||||||
|
}
|
||||||
|
return belegUrl.split("/").pop() ?? "Beleg";
|
||||||
|
}
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
breadcrumbs: (data: { companyId: string; companyName: string }) => [
|
breadcrumbs: (data: { companyId: string; companyName: string }) => [
|
||||||
{ label: "Mandanten", href: "/companies" },
|
{ label: "Mandanten", href: "/companies" },
|
||||||
@@ -35,6 +57,7 @@ interface Einnahme {
|
|||||||
zahlungsart: "KASSE" | "BANK";
|
zahlungsart: "KASSE" | "BANK";
|
||||||
datum: string;
|
datum: string;
|
||||||
beschreibung: string | null;
|
beschreibung: string | null;
|
||||||
|
belegUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
@@ -44,6 +67,7 @@ const emptyForm = {
|
|||||||
zahlungsart: "BANK" as "KASSE" | "BANK",
|
zahlungsart: "BANK" as "KASSE" | "BANK",
|
||||||
datum: new Date().toISOString().slice(0, 10),
|
datum: new Date().toISOString().slice(0, 10),
|
||||||
beschreibung: "",
|
beschreibung: "",
|
||||||
|
belegUrl: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
|
||||||
@@ -99,6 +123,7 @@ export async function loader({ request, params }: { request: Request; params: {
|
|||||||
zahlungsart: (e.zahlungsart as "KASSE" | "BANK") || "BANK",
|
zahlungsart: (e.zahlungsart as "KASSE" | "BANK") || "BANK",
|
||||||
datum: e.date.toISOString(),
|
datum: e.date.toISOString(),
|
||||||
beschreibung: e.description,
|
beschreibung: e.description,
|
||||||
|
belegUrl: e.belegUrl ?? null,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -113,6 +138,47 @@ export default function EinnahmenPage() {
|
|||||||
const [loadingYear, setLoadingYear] = useState(false);
|
const [loadingYear, setLoadingYear] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
const [view, setView] = useState<"pivot" | "liste">("liste");
|
||||||
|
|
||||||
|
// File upload state
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const [uploadingBeleg, setUploadingBeleg] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// Quick-upload from list view (without opening dialog)
|
||||||
|
const [quickUploadId, setQuickUploadId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleFileDrop = useCallback((file: File) => {
|
||||||
|
const allowed = ["image/jpeg", "image/png", "image/webp", "image/gif", "application/pdf"];
|
||||||
|
if (!allowed.includes(file.type)) {
|
||||||
|
setUploadError("Nur PDF, JPG, PNG, WebP oder GIF erlaubt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
setUploadError("Datei zu groß (max. 10 MB).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadError(null);
|
||||||
|
setPendingFile(file);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleQuickUpload(buchungId: string, file: File) {
|
||||||
|
const allowed = ["image/jpeg", "image/png", "image/webp", "image/gif", "application/pdf"];
|
||||||
|
if (!allowed.includes(file.type) || file.size > 10 * 1024 * 1024) return;
|
||||||
|
setQuickUploadId(buchungId);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch(`/api/einnahmen/${buchungId}/upload`, { method: "POST", body: fd });
|
||||||
|
if (res.ok) {
|
||||||
|
await loadYear(year);
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setQuickUploadId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@@ -134,18 +200,23 @@ export default function EinnahmenPage() {
|
|||||||
zahlungsart: ((e.zahlungsart as string) || "BANK") as "KASSE" | "BANK",
|
zahlungsart: ((e.zahlungsart as string) || "BANK") as "KASSE" | "BANK",
|
||||||
datum: e.date as string,
|
datum: e.date as string,
|
||||||
beschreibung: (e.description as string | null) ?? null,
|
beschreibung: (e.description as string | null) ?? null,
|
||||||
|
belegUrl: (e.belegUrl as string | null) ?? null,
|
||||||
})));
|
})));
|
||||||
setLoadingYear(false);
|
setLoadingYear(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
setPendingFile(null);
|
||||||
|
setUploadError(null);
|
||||||
setForm({ ...emptyForm, datum: `${year}-01-01`, kategorie: kategorien[0] ?? "" });
|
setForm({ ...emptyForm, datum: `${year}-01-01`, kategorie: kategorien[0] ?? "" });
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(e: Einnahme) {
|
function openEdit(e: Einnahme) {
|
||||||
setEditingId(e.id);
|
setEditingId(e.id);
|
||||||
|
setPendingFile(null);
|
||||||
|
setUploadError(null);
|
||||||
setForm({
|
setForm({
|
||||||
kategorie: e.kategorie,
|
kategorie: e.kategorie,
|
||||||
betrag: String(e.betrag),
|
betrag: String(e.betrag),
|
||||||
@@ -153,12 +224,14 @@ export default function EinnahmenPage() {
|
|||||||
zahlungsart: e.zahlungsart,
|
zahlungsart: e.zahlungsart,
|
||||||
datum: e.datum.slice(0, 10),
|
datum: e.datum.slice(0, 10),
|
||||||
beschreibung: e.beschreibung ?? "",
|
beschreibung: e.beschreibung ?? "",
|
||||||
|
belegUrl: e.belegUrl ?? "",
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setUploadError(null);
|
||||||
const payload = {
|
const payload = {
|
||||||
kategorie: form.kategorie,
|
kategorie: form.kategorie,
|
||||||
betrag: parseFloat(form.betrag),
|
betrag: parseFloat(form.betrag),
|
||||||
@@ -166,29 +239,59 @@ export default function EinnahmenPage() {
|
|||||||
zahlungsart: form.zahlungsart,
|
zahlungsart: form.zahlungsart,
|
||||||
datum: form.datum,
|
datum: form.datum,
|
||||||
beschreibung: form.beschreibung || undefined,
|
beschreibung: form.beschreibung || undefined,
|
||||||
|
belegUrl: form.belegUrl || undefined,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
let savedId: string;
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await fetch(`/api/einnahmen/${editingId}`, {
|
const res = await fetch(`/api/einnahmen/${editingId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error("Speichern fehlgeschlagen.");
|
||||||
|
savedId = editingId;
|
||||||
} else {
|
} else {
|
||||||
await fetch("/api/einnahmen", {
|
const res = await fetch("/api/einnahmen", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...payload, companyId }),
|
body: JSON.stringify({ ...payload, companyId }),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) throw new Error("Erstellen fehlgeschlagen.");
|
||||||
|
const created = await res.json() as { id?: string };
|
||||||
|
savedId = created.id ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload pending file after entry is saved
|
||||||
|
if (pendingFile && savedId) {
|
||||||
|
setUploadingBeleg(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", pendingFile);
|
||||||
|
const upRes = await fetch(`/api/einnahmen/${savedId}/upload`, { method: "POST", body: fd });
|
||||||
|
if (!upRes.ok) {
|
||||||
|
const err = await upRes.json().catch(() => ({ error: "Upload fehlgeschlagen." })) as { error?: string };
|
||||||
|
throw new Error(err.error ?? "Upload fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
setPendingFile(null);
|
||||||
|
}
|
||||||
|
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
await loadYear(year);
|
await loadYear(year);
|
||||||
revalidate();
|
revalidate();
|
||||||
|
} catch (e) {
|
||||||
|
setUploadError(e instanceof Error ? e.message : "Unbekannter Fehler.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
setUploadingBeleg(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDeleteBeleg(id: string) {
|
||||||
|
await fetch(`/api/einnahmen/${id}/upload`, { method: "DELETE" });
|
||||||
|
await loadYear(year);
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Eintrag wirklich löschen?")) return;
|
if (!confirm("Eintrag wirklich löschen?")) return;
|
||||||
setDeleting(id);
|
setDeleting(id);
|
||||||
@@ -243,6 +346,33 @@ export default function EinnahmenPage() {
|
|||||||
<p className="text-gray-500 mt-1">{companyName} · {year}</p>
|
<p className="text-gray-500 mt-1">{companyName} · {year}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* View toggle */}
|
||||||
|
<div className="flex rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setView("liste")}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
|
view === "liste"
|
||||||
|
? "bg-emerald-600 text-white"
|
||||||
|
: "bg-white text-gray-500 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
title="Listenansicht"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
Liste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView("pivot")}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors border-l border-gray-200 ${
|
||||||
|
view === "pivot"
|
||||||
|
? "bg-emerald-600 text-white"
|
||||||
|
: "bg-white text-gray-500 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
title="Übersicht"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
Übersicht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<select
|
<select
|
||||||
value={year}
|
value={year}
|
||||||
onChange={(e) => loadYear(Number(e.target.value))}
|
onChange={(e) => loadYear(Number(e.target.value))}
|
||||||
@@ -297,7 +427,7 @@ export default function EinnahmenPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pivottabelle */}
|
{/* Pivottabelle / Listenansicht */}
|
||||||
{loadingYear ? (
|
{loadingYear ? (
|
||||||
<div className="flex items-center justify-center py-16 text-gray-400">
|
<div className="flex items-center justify-center py-16 text-gray-400">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||||
@@ -313,6 +443,147 @@ export default function EinnahmenPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : view === "liste" ? (
|
||||||
|
/* ── LISTENANSICHT ─────────────────────────────────────── */
|
||||||
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 bg-slate-50">
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Datum</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Kategorie</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Notiz</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Brutto</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">MwSt.</th>
|
||||||
|
<th className="px-3 py-2.5 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Netto</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">Zahlung</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||||
|
<span className="inline-flex items-center gap-1"><Paperclip className="h-3 w-3" />Beleg</span>
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2.5 w-20" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{[...einnahmen].sort((a, b) => b.datum.localeCompare(a.datum)).map((e) => {
|
||||||
|
const rate = e.steuersatz / 100;
|
||||||
|
const netto = rate > 0 ? Math.round((e.betrag / (1 + rate)) * 100) / 100 : e.betrag;
|
||||||
|
return (
|
||||||
|
<tr key={e.id} className="hover:bg-slate-50/60 group">
|
||||||
|
<td className="px-4 py-2.5 text-slate-600 whitespace-nowrap">
|
||||||
|
{new Date(e.datum).toLocaleDateString("de-DE")}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-slate-700 font-medium whitespace-nowrap">
|
||||||
|
{e.kategorie}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-slate-400 text-xs truncate max-w-[14rem]">
|
||||||
|
{e.beschreibung ?? <span className="text-slate-300">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-medium text-emerald-700 whitespace-nowrap">
|
||||||
|
{formatCurrency(e.betrag)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
{e.steuersatz > 0 ? (
|
||||||
|
<Badge variant="secondary">{e.steuersatz} %</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-slate-600 whitespace-nowrap">
|
||||||
|
{formatCurrency(netto)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
{e.zahlungsart === "BANK" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-blue-600 font-medium">
|
||||||
|
<Landmark className="h-3 w-3" /> Bank
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium">
|
||||||
|
<Banknote className="h-3 w-3" /> Kasse
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
{e.belegUrl ? (
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
href={belegHref(e.belegUrl) ?? "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-emerald-600 hover:text-emerald-800 font-medium max-w-[8rem] truncate"
|
||||||
|
title={e.belegUrl}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{belegDisplayName(e.belegUrl)}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : quickUploadId === e.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-500 mx-auto" />
|
||||||
|
) : (
|
||||||
|
<label
|
||||||
|
htmlFor={`quick-upload-${e.id}`}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-slate-300 hover:text-emerald-600 transition-colors cursor-pointer"
|
||||||
|
title="Beleg hochladen"
|
||||||
|
>
|
||||||
|
<Paperclip className="h-3.5 w-3.5" />
|
||||||
|
<input
|
||||||
|
id={`quick-upload-${e.id}`}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.webp,.gif,application/pdf,image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(ev) => {
|
||||||
|
const file = ev.target.files?.[0];
|
||||||
|
if (file) handleQuickUpload(e.id, file);
|
||||||
|
ev.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(e)}
|
||||||
|
className="p-1 rounded hover:bg-slate-100 text-slate-500 hover:text-slate-700"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(e.id)}
|
||||||
|
disabled={deleting === e.id}
|
||||||
|
className="p-1 rounded hover:bg-red-50 text-slate-400 hover:text-red-600"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
{deleting === e.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-slate-300 bg-slate-50">
|
||||||
|
<td colSpan={3} className="px-4 py-2.5 text-xs font-bold text-slate-700">
|
||||||
|
Gesamt ({einnahmen.length} Einträge)
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right text-xs font-bold text-emerald-600 whitespace-nowrap">
|
||||||
|
{formatCurrency(gesamt)}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td className="px-3 py-2.5 text-right text-xs font-bold text-slate-600 whitespace-nowrap">
|
||||||
|
{formatCurrency(gesamt - ustGesamt)}
|
||||||
|
</td>
|
||||||
|
<td colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -600,17 +871,110 @@ export default function EinnahmenPage() {
|
|||||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Paperclip className="h-3.5 w-3.5" />
|
||||||
|
Beleg
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Pending file preview */}
|
||||||
|
{pendingFile ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-sm">
|
||||||
|
<FileText className="h-5 w-5 text-emerald-600 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-emerald-800 truncate">{pendingFile.name}</p>
|
||||||
|
<p className="text-xs text-emerald-600">{(pendingFile.size / 1024).toFixed(0)} KB</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingFile(null)}
|
||||||
|
className="shrink-0 rounded-full p-0.5 hover:bg-emerald-100 text-emerald-500 hover:text-emerald-700"
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : form.belegUrl ? (
|
||||||
|
/* Existing uploaded beleg */
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2.5 text-sm">
|
||||||
|
<FileText className="h-5 w-5 text-gray-500 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<a
|
||||||
|
href={belegHref(form.belegUrl) ?? "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-emerald-600 hover:text-emerald-800 truncate block"
|
||||||
|
title={form.belegUrl}
|
||||||
|
>
|
||||||
|
{belegDisplayName(form.belegUrl)}
|
||||||
|
</a>
|
||||||
|
<p className="text-xs text-gray-400">Vorhandener Beleg · neuen hochladen zum Ersetzen</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, belegUrl: "" }))}
|
||||||
|
className="shrink-0 rounded-full p-0.5 hover:bg-gray-100 text-gray-400 hover:text-red-500"
|
||||||
|
title="Beleg entfernen"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Drag & drop zone — always shown so user can replace/add */}
|
||||||
|
{!pendingFile && (
|
||||||
|
<label
|
||||||
|
htmlFor="beleg-file-input"
|
||||||
|
onDragOver={(ev) => { ev.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = ev.dataTransfer.files[0];
|
||||||
|
if (file) handleFileDrop(file);
|
||||||
|
}}
|
||||||
|
className={`flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-6 cursor-pointer transition-colors select-none mt-2
|
||||||
|
${dragOver ? "border-emerald-400 bg-emerald-50 text-emerald-700" : "border-gray-200 hover:border-emerald-300 hover:bg-gray-50 text-gray-400"}`}
|
||||||
|
>
|
||||||
|
<Upload className="h-6 w-6" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{form.belegUrl ? "Anderen Beleg hochladen" : "Datei hier ablegen oder klicken"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">PDF, JPG, PNG, WebP, GIF · max. 10 MB</p>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadError && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-600 font-medium">{uploadError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="beleg-file-input"
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.jpg,.jpeg,.png,.webp,.gif,application/pdf,image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFileDrop(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>Abbrechen</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || !formValid}
|
disabled={saving || uploadingBeleg || !formValid}
|
||||||
className="bg-emerald-600 hover:bg-emerald-700"
|
className="bg-emerald-600 hover:bg-emerald-700"
|
||||||
>
|
>
|
||||||
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
{(saving || uploadingBeleg) && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{editingId ? "Speichern" : "Hinzufügen"}
|
{uploadingBeleg ? "Beleg wird hochgeladen…" : editingId ? "Speichern" : "Hinzufügen"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
|
||||||
export const handle = {
|
export const handle = {
|
||||||
@@ -18,6 +19,7 @@ export default function NewCompanyPage() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -230,6 +230,12 @@ export default function CompaniesPage() {
|
|||||||
|
|
||||||
{/* Aktionen */}
|
{/* Aktionen */}
|
||||||
<div className="flex gap-2 p-4 border-b border-slate-100">
|
<div className="flex gap-2 p-4 border-b border-slate-100">
|
||||||
|
<Button variant="outline" size="sm" asChild className="flex-1">
|
||||||
|
<Link to={`/companies/${selected.id}`}>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm" asChild className="flex-1">
|
<Button variant="outline" size="sm" asChild className="flex-1">
|
||||||
<Link to={`/companies/${selected.id}/edit`}>
|
<Link to={`/companies/${selected.id}/edit`}>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
|
|||||||
+21
-3
@@ -1,10 +1,18 @@
|
|||||||
import { createCookieSessionStorage, redirect } from "react-router";
|
import { createCookieSessionStorage, redirect } from "react-router";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
import prisma from "@/lib/prisma.server";
|
import prisma from "@/lib/prisma.server";
|
||||||
import { log } from "@/lib/logger.server";
|
import { log } from "@/lib/logger.server";
|
||||||
|
|
||||||
if (!process.env.AUTH_SECRET) {
|
/**
|
||||||
throw new Error("AUTH_SECRET environment variable is required");
|
* AUTH_SECRET wird nur aus .env gelesen, falls die Umgebungsvariable nicht existiert.
|
||||||
|
* Falls nicht gesetzt, wird eine zufällige generiert.
|
||||||
|
* Bei jedem Containerstart mit ephemerem Secret werden alle bestehenden Sessions invalidiert.
|
||||||
|
*/
|
||||||
|
const AUTH_SECRET = process.env.AUTH_SECRET || randomBytes(32).toString("base64");
|
||||||
|
|
||||||
|
if (!AUTH_SECRET) {
|
||||||
|
throw new Error("AUTH_SECRET could not be generated");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionStorage = createCookieSessionStorage({
|
const sessionStorage = createCookieSessionStorage({
|
||||||
@@ -14,7 +22,7 @@ const sessionStorage = createCookieSessionStorage({
|
|||||||
maxAge: 60 * 60 * 4, // 4 Stunden
|
maxAge: 60 * 60 * 4, // 4 Stunden
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secrets: [process.env.AUTH_SECRET],
|
secrets: [AUTH_SECRET],
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -64,6 +72,7 @@ export async function createUserSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserSession(request: Request) {
|
export async function getUserSession(request: Request) {
|
||||||
|
try {
|
||||||
const session = await sessionStorage.getSession(
|
const session = await sessionStorage.getSession(
|
||||||
request.headers.get("Cookie")
|
request.headers.get("Cookie")
|
||||||
);
|
);
|
||||||
@@ -72,6 +81,15 @@ export async function getUserSession(request: Request) {
|
|||||||
userName: session.get("userName") as string | undefined,
|
userName: session.get("userName") as string | undefined,
|
||||||
userRole: session.get("userRole") as string | undefined,
|
userRole: session.get("userRole") as string | undefined,
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Session-Cookie ist ungültig (z.B. nach Neustart mit neuem AUTH_SECRET)
|
||||||
|
// Gib eine leere Session zurück, damit der Nutzer zum Login weitergeleitet wird
|
||||||
|
return {
|
||||||
|
userId: undefined,
|
||||||
|
userName: undefined,
|
||||||
|
userRole: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireUser(request: Request) {
|
export async function requireUser(request: Request) {
|
||||||
|
|||||||
+2
-1
@@ -21,8 +21,9 @@ services:
|
|||||||
|
|
||||||
app:
|
app:
|
||||||
# registry.henryathome.home64.de/henry/annasrechnungsmanager:latest
|
# registry.henryathome.home64.de/henry/annasrechnungsmanager:latest
|
||||||
image: annasrechnungsmanager:latest
|
image: git.henryathome.home64.de/henry/annasrechnungsmanager:latest
|
||||||
container_name: annas_app
|
container_name: annas_app
|
||||||
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"plugin": [
|
||||||
|
".opencode/plugins/graphify.js",
|
||||||
|
"superpowers@git+https://github.com/obra/superpowers.git"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+1397
-18
File diff suppressed because it is too large
Load Diff
+12
-1
@@ -10,6 +10,10 @@
|
|||||||
"start": "react-router-serve ./build/server/index.js",
|
"start": "react-router-serve ./build/server/index.js",
|
||||||
"typecheck": "react-router typegen && tsc",
|
"typecheck": "react-router typegen && tsc",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"db:migrate": "prisma migrate dev",
|
"db:migrate": "prisma migrate dev",
|
||||||
"db:seed": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
|
"db:seed": "npx ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
@@ -29,6 +33,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@react-router/node": "^7.13.1",
|
"@react-router/node": "^7.13.1",
|
||||||
"@react-router/serve": "^7",
|
"@react-router/serve": "^7",
|
||||||
@@ -53,15 +58,21 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@react-router/dev": "^7.13.1",
|
"@react-router/dev": "^7.13.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^4",
|
"@vitejs/plugin-react": "^4",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
|
"@vitest/ui": "^4.1.5",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite": "^6",
|
"vite": "^6",
|
||||||
"vite-tsconfig-paths": "^5"
|
"vite-tsconfig-paths": "^5",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `buchungen` ADD COLUMN `belegUrl` LONGTEXT;
|
||||||
@@ -115,6 +115,7 @@ model Buchung {
|
|||||||
steuersatz Int? // Tax rate: 0, 7, 19 (nullable for non-business records)
|
steuersatz Int? // Tax rate: 0, 7, 19 (nullable for non-business records)
|
||||||
zahlungsart Zahlungsart? // KASSE or BANK
|
zahlungsart Zahlungsart? // KASSE or BANK
|
||||||
isBusinessRecord Boolean @default(false) // True if this is from Einnahme/Ausgabe
|
isBusinessRecord Boolean @default(false) // True if this is from Einnahme/Ausgabe
|
||||||
|
belegUrl String? @db.Text // Optional receipt/document reference URL
|
||||||
linkedBuchungId String?
|
linkedBuchungId String?
|
||||||
linkedBuchung Buchung? @relation("BuchungLink", fields: [linkedBuchungId], references: [id], onDelete: SetNull)
|
linkedBuchung Buchung? @relation("BuchungLink", fields: [linkedBuchungId], references: [id], onDelete: SetNull)
|
||||||
linkedFrom Buchung[] @relation("BuchungLink")
|
linkedFrom Buchung[] @relation("BuchungLink")
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Tests - Annas RechnungsManager
|
||||||
|
|
||||||
|
Vitest-basiertes Test-Framework für kritische Geschäftslogik.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (already done)
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm run test # Single run
|
||||||
|
npm run test:watch # Watch mode
|
||||||
|
npm run test:ui # Browser UI
|
||||||
|
npm run test:coverage # With coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── lib/
|
||||||
|
│ ├── tax.test.ts # Steuerberechnungen (§14 UStG)
|
||||||
|
│ └── schemas.test.ts # Zod-Validierung (Input-Sicherheit)
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
- ✅ **tax.ts** - German tax calculations (19%, 7%, Kleinunternehmer)
|
||||||
|
- ✅ **schemas.ts** - Input validation (Zod schemas)
|
||||||
|
- ⚠️ **invoice-number.server.ts** - Requires Prisma mocking (TODO)
|
||||||
|
|
||||||
|
## Critical Test Areas
|
||||||
|
|
||||||
|
1. **Tax Calculations** - Must be correct per German tax law
|
||||||
|
2. **Input Validation** - Prevent invalid data in DB
|
||||||
|
3. **Invoice Logic** - §14 UStG compliance
|
||||||
|
4. **Buchhaltung** - Double-entry bookkeeping accuracy
|
||||||
|
|
||||||
|
## Running Specific Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run only tax tests
|
||||||
|
npx vitest run tests/lib/tax.test.ts
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
# Opens: ./coverage/index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { myFunction } from "@/lib/my-module";
|
||||||
|
|
||||||
|
describe("myModule", () => {
|
||||||
|
it("should do something", () => {
|
||||||
|
expect(myFunction()).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
Tests laufen automatisch in der Gitea Actions Pipeline (`.gitea/workflows/build.yml`).
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
|
||||||
|
import { InvoiceStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
describe("InvoiceStatusBadge", () => {
|
||||||
|
it("should render DRAFT status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.DRAFT} />);
|
||||||
|
expect(screen.getByText("Entwurf")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render SENT status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.SENT} />);
|
||||||
|
expect(screen.getByText("Versendet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render PAID status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.PAID} />);
|
||||||
|
expect(screen.getByText("Bezahlt")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render CANCELLED status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.CANCELLED} />);
|
||||||
|
expect(screen.getByText("Storniert")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render DELETED status correctly", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.DELETED} />);
|
||||||
|
expect(screen.getByText("Gelöscht")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have proper badge structure", () => {
|
||||||
|
render(<InvoiceStatusBadge status={InvoiceStatus.PAID} />);
|
||||||
|
const badge = screen.getByText("Bezahlt");
|
||||||
|
expect(badge).toBeInTheDocument();
|
||||||
|
// Badge should be a div element
|
||||||
|
expect(badge.tagName).toBe("DIV");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple Integration Tests
|
||||||
|
*
|
||||||
|
* These tests verify that the API logic works correctly
|
||||||
|
* without requiring a full database connection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("API Integration (Simple)", () => {
|
||||||
|
describe("Company API Logic", () => {
|
||||||
|
it("should validate company creation data", async () => {
|
||||||
|
const { companySchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const validCompany = {
|
||||||
|
name: "Test GmbH",
|
||||||
|
address: "Hauptstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
invoicePrefix: "RE",
|
||||||
|
kleinunternehmer: false,
|
||||||
|
taxId: null,
|
||||||
|
vatId: null,
|
||||||
|
bankIban: null,
|
||||||
|
bankBic: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = companySchema.safeParse(validCompany);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid company data", async () => {
|
||||||
|
const { companySchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const invalidCompany = {
|
||||||
|
name: "", // Invalid: empty name
|
||||||
|
address: "Test St. 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = companySchema.safeParse(invalidCompany);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Invoice API Logic", () => {
|
||||||
|
it("should validate invoice creation data", async () => {
|
||||||
|
const { invoiceSchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const validInvoice = {
|
||||||
|
companyId: "company-123",
|
||||||
|
customerId: "customer-456",
|
||||||
|
issueDate: "2026-05-08",
|
||||||
|
dueDate: "2026-06-08",
|
||||||
|
kleinunternehmer: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Web Development",
|
||||||
|
quantity: 10,
|
||||||
|
unit: "Stunden",
|
||||||
|
unitPrice: 100,
|
||||||
|
taxRate: 19,
|
||||||
|
netAmount: 1000,
|
||||||
|
taxAmount: 190,
|
||||||
|
grossAmount: 1190,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
netTotal: 1000,
|
||||||
|
taxTotal: 190,
|
||||||
|
grossTotal: 1190,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = invoiceSchema.safeParse(validInvoice);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invoice with no items", async () => {
|
||||||
|
const { invoiceSchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const invalidInvoice = {
|
||||||
|
companyId: "company-123",
|
||||||
|
customerId: "customer-456",
|
||||||
|
issueDate: "2026-05-08",
|
||||||
|
dueDate: "2026-06-08",
|
||||||
|
items: [], // Invalid: no items
|
||||||
|
netTotal: 0,
|
||||||
|
taxTotal: 0,
|
||||||
|
grossTotal: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = invoiceSchema.safeParse(invalidInvoice);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Customer API Logic", () => {
|
||||||
|
it("should validate customer data", async () => {
|
||||||
|
const { customerSchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const validCustomer = {
|
||||||
|
companyId: "company-123",
|
||||||
|
name: "Max Mustermann",
|
||||||
|
address: "Musterstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = customerSchema.safeParse(validCustomer);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require companyId", async () => {
|
||||||
|
const { customerSchema } = await import("@/lib/schemas");
|
||||||
|
|
||||||
|
const invalidCustomer = {
|
||||||
|
name: "Max Mustermann",
|
||||||
|
address: "Musterstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = customerSchema.safeParse(invalidCustomer);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Tax Calculation Integration", () => {
|
||||||
|
it("should calculate invoice totals from items", async () => {
|
||||||
|
const { calcItemAmounts, calcInvoiceTotals } = await import("@/lib/tax");
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
quantity: 10,
|
||||||
|
unitPrice: 100,
|
||||||
|
taxRate: 19,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quantity: 5,
|
||||||
|
unitPrice: 200,
|
||||||
|
taxRate: 7,
|
||||||
|
},
|
||||||
|
].map((item, idx) => ({
|
||||||
|
position: idx + 1,
|
||||||
|
description: `Service ${idx + 1}`,
|
||||||
|
...item,
|
||||||
|
...calcItemAmounts(item.quantity, item.unitPrice, item.taxRate),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totals = calcInvoiceTotals(items);
|
||||||
|
|
||||||
|
expect(totals.netTotal).toBe(2000); // 1000 + 1000
|
||||||
|
expect(totals.taxTotal).toBe(260); // 190 + 70 (5*200*7% = 70)
|
||||||
|
expect(totals.grossTotal).toBe(2260); // 1190 + 1070
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle Kleinunternehmer calculation", async () => {
|
||||||
|
const { calcItemAmountsKleinunternehmer, calcInvoiceTotals } = await import("@/lib/tax");
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
quantity: 10,
|
||||||
|
unitPrice: 100,
|
||||||
|
},
|
||||||
|
].map((item, idx) => ({
|
||||||
|
position: idx + 1,
|
||||||
|
description: "Service",
|
||||||
|
...item,
|
||||||
|
...calcItemAmountsKleinunternehmer(item.quantity, item.unitPrice),
|
||||||
|
taxRate: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totals = calcInvoiceTotals(items);
|
||||||
|
|
||||||
|
expect(totals.netTotal).toBe(1000);
|
||||||
|
expect(totals.taxTotal).toBe(0);
|
||||||
|
expect(totals.grossTotal).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Authentication Logic", () => {
|
||||||
|
it("should identify unauthorized requests", () => {
|
||||||
|
const user = null;
|
||||||
|
const isAuthenticated = user !== null;
|
||||||
|
expect(isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should identify authorized requests", () => {
|
||||||
|
const user = { id: "user-123", role: "ADMIN" };
|
||||||
|
const isAuthenticated = user !== null;
|
||||||
|
expect(isAuthenticated).toBe(true);
|
||||||
|
expect(user.role).toBe("ADMIN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import { testPrisma, setupTestDatabase, cleanupTestDatabase, createTestUser, createTestCompany, createTestCustomer } from "./setup";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration Tests for API Routes
|
||||||
|
*
|
||||||
|
* These tests require a test database.
|
||||||
|
* They will skip gracefully if the database is not available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("API Integration Tests (Database Required)", () => {
|
||||||
|
let testUser: any;
|
||||||
|
let testCompany: any;
|
||||||
|
let testCustomer: any;
|
||||||
|
let dbAvailable = false;
|
||||||
|
|
||||||
|
// Setup before all tests
|
||||||
|
beforeAll(async () => {
|
||||||
|
dbAvailable = await setupTestDatabase();
|
||||||
|
|
||||||
|
if (!dbAvailable) {
|
||||||
|
console.warn("Skipping database integration tests - no test database available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
testUser = await createTestUser("test@example.com", "testuser");
|
||||||
|
|
||||||
|
// Create test company
|
||||||
|
testCompany = await createTestCompany(testUser.id, "Integration Test GmbH");
|
||||||
|
|
||||||
|
// Create test customer
|
||||||
|
testCustomer = await createTestCustomer(testCompany.id, "Integration Test Customer");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup after all tests
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!dbAvailable) return;
|
||||||
|
await cleanupTestDatabase();
|
||||||
|
await testPrisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean data between tests (but keep user/company/customer)
|
||||||
|
beforeEach(async () => {
|
||||||
|
if (!dbAvailable) return;
|
||||||
|
|
||||||
|
// Delete invoices and related items
|
||||||
|
await testPrisma.invoiceItem.deleteMany({
|
||||||
|
where: { invoice: { companyId: testCompany.id } },
|
||||||
|
});
|
||||||
|
await testPrisma.invoice.deleteMany({
|
||||||
|
where: { companyId: testCompany.id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to skip tests if no database
|
||||||
|
const dbTest = (name: string, fn: () => Promise<void>) => {
|
||||||
|
it(name, async () => {
|
||||||
|
if (!dbAvailable) {
|
||||||
|
console.warn(`Skipping "${name}" - no test database`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fn();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Companies API", () => {
|
||||||
|
dbTest("should list companies for a user", async () => {
|
||||||
|
const companies = await testPrisma.company.findMany({
|
||||||
|
where: { userId: testUser.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(companies).toBeDefined();
|
||||||
|
expect(companies.length).toBeGreaterThan(0);
|
||||||
|
expect(companies[0].name).toBe("Integration Test GmbH");
|
||||||
|
});
|
||||||
|
|
||||||
|
dbTest("should create a new company", async () => {
|
||||||
|
const newCompanyData = {
|
||||||
|
name: "New Test Company",
|
||||||
|
address: "New Street 1",
|
||||||
|
zip: "99999",
|
||||||
|
city: "Munich",
|
||||||
|
country: "DE",
|
||||||
|
};
|
||||||
|
|
||||||
|
const company = await testPrisma.company.create({
|
||||||
|
data: {
|
||||||
|
...newCompanyData,
|
||||||
|
userId: testUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(company).toBeDefined();
|
||||||
|
expect(company.name).toBe("New Test Company");
|
||||||
|
expect(company.userId).toBe(testUser.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Invoices API", () => {
|
||||||
|
dbTest("should create a new invoice", async () => {
|
||||||
|
const { calcItemAmounts, calcInvoiceTotals } = await import("@/lib/tax");
|
||||||
|
|
||||||
|
const invoiceData = {
|
||||||
|
companyId: testCompany.id,
|
||||||
|
customerId: testCustomer.id,
|
||||||
|
issueDate: new Date(),
|
||||||
|
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
kleinunternehmer: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Test Service",
|
||||||
|
quantity: 10,
|
||||||
|
unit: "Stunden",
|
||||||
|
unitPrice: 100,
|
||||||
|
taxRate: 19,
|
||||||
|
netAmount: 1000,
|
||||||
|
taxAmount: 190,
|
||||||
|
grossAmount: 1190,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recalculate server-side (as the API does)
|
||||||
|
const recalculatedItems = invoiceData.items.map(item => ({
|
||||||
|
...item,
|
||||||
|
...calcItemAmounts(item.quantity, item.unitPrice, item.taxRate),
|
||||||
|
}));
|
||||||
|
const totals = calcInvoiceTotals(recalculatedItems);
|
||||||
|
|
||||||
|
const invoice = await testPrisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
companyId: invoiceData.companyId,
|
||||||
|
customerId: invoiceData.customerId,
|
||||||
|
issueDate: invoiceData.issueDate,
|
||||||
|
dueDate: invoiceData.dueDate,
|
||||||
|
status: "DRAFT",
|
||||||
|
kleinunternehmer: invoiceData.kleinunternehmer,
|
||||||
|
netTotal: totals.netTotal,
|
||||||
|
taxTotal: totals.taxTotal,
|
||||||
|
grossTotal: totals.grossTotal,
|
||||||
|
items: {
|
||||||
|
create: recalculatedItems.map((item, idx) => ({
|
||||||
|
position: idx + 1,
|
||||||
|
description: item.description,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
taxRate: item.taxRate,
|
||||||
|
netAmount: item.netAmount,
|
||||||
|
taxAmount: item.taxAmount,
|
||||||
|
grossAmount: item.grossAmount,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { items: true, customer: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(invoice).toBeDefined();
|
||||||
|
expect(invoice.status).toBe("DRAFT");
|
||||||
|
expect(invoice.items).toHaveLength(1);
|
||||||
|
expect(invoice.grossTotal).toBe(1190);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Buchungen API", () => {
|
||||||
|
dbTest("should create a new Buchung", async () => {
|
||||||
|
const buchungData = {
|
||||||
|
companyId: testCompany.id,
|
||||||
|
date: new Date(),
|
||||||
|
account: "KASSE" as const,
|
||||||
|
type: "EINLAGE" as const,
|
||||||
|
amount: 1000,
|
||||||
|
description: "Initial investment",
|
||||||
|
isBusinessRecord: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buchung = await testPrisma.buchung.create({
|
||||||
|
data: buchungData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buchung).toBeDefined();
|
||||||
|
expect(buchung.account).toBe("KASSE");
|
||||||
|
expect(buchung.type).toBe("EINLAGE");
|
||||||
|
expect(buchung.amount.toNumber()).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration Test Setup
|
||||||
|
* Uses a separate test database to avoid polluting development data
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ||
|
||||||
|
"mysql://annas_user:annas_password@localhost:3306/annas_rechnungen_test";
|
||||||
|
|
||||||
|
export const testPrisma = new PrismaClient({
|
||||||
|
datasources: {
|
||||||
|
db: {
|
||||||
|
url: TEST_DATABASE_URL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup test database: push schema and seed if needed
|
||||||
|
* Returns true if successful, false if database unavailable
|
||||||
|
*/
|
||||||
|
export async function setupTestDatabase(): Promise<boolean> {
|
||||||
|
console.log("Setting up test database...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Push schema to test database
|
||||||
|
execSync(`npx prisma db push --force-reset`, {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL: TEST_DATABASE_URL,
|
||||||
|
},
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Test database ready");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Test database not available:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up test database
|
||||||
|
*/
|
||||||
|
export async function cleanupTestDatabase() {
|
||||||
|
// Delete all data in correct order (respecting foreign keys)
|
||||||
|
const tables = [
|
||||||
|
"audit_logs",
|
||||||
|
"invoice_items",
|
||||||
|
"invoices",
|
||||||
|
"buchung_kategorien",
|
||||||
|
"buchungen",
|
||||||
|
"anlagegueter",
|
||||||
|
"services",
|
||||||
|
"customers",
|
||||||
|
"companies",
|
||||||
|
"users",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
await testPrisma.$executeRawUnsafe(`DELETE FROM ${table}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test user
|
||||||
|
*/
|
||||||
|
export async function createTestUser(email: string, username: string) {
|
||||||
|
const bcrypt = await import("bcryptjs");
|
||||||
|
const passwordHash = await bcrypt.default.hash("test1234", 10);
|
||||||
|
|
||||||
|
return testPrisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
name: "Test User",
|
||||||
|
passwordHash,
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test company for a user
|
||||||
|
*/
|
||||||
|
export async function createTestCompany(userId: string, name: string = "Test GmbH") {
|
||||||
|
return testPrisma.company.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
address: "Teststraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test customer for a company
|
||||||
|
*/
|
||||||
|
export async function createTestCustomer(companyId: string, name: string = "Test Customer") {
|
||||||
|
return testPrisma.customer.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
address: "Kundenstraße 1",
|
||||||
|
zip: "54321",
|
||||||
|
city: "Hamburg",
|
||||||
|
country: "DE",
|
||||||
|
companyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate auth cookie for test requests
|
||||||
|
* (Simplified - in real scenario, you'd create a session)
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(userId: string) {
|
||||||
|
// For integration tests, we might mock the session or use a test session
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Test-User-Id": userId, // Custom header for test auth bypass
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
jahresAfa,
|
||||||
|
erwerbsjahrAfa,
|
||||||
|
afaFuerJahr,
|
||||||
|
kumulierteAfa,
|
||||||
|
buchwert,
|
||||||
|
assetStatus,
|
||||||
|
AnlagegutRaw,
|
||||||
|
} from "@/lib/afa";
|
||||||
|
|
||||||
|
describe("afa.ts - Asset Depreciation (§7 EStG)", () => {
|
||||||
|
const sampleAsset: AnlagegutRaw = {
|
||||||
|
anschaffungskosten: 12000,
|
||||||
|
nutzungsdauerJahre: 3,
|
||||||
|
restwert: 0,
|
||||||
|
anschaffungsdatum: "2024-03-15", // March 15, 2024
|
||||||
|
aktiv: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("jahresAfa", () => {
|
||||||
|
it("should calculate full annual depreciation", () => {
|
||||||
|
// 12000 over 3 years = 4000 per year
|
||||||
|
expect(jahresAfa(12000, 0, 3)).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle residual value (restwert)", () => {
|
||||||
|
// 12000 - 2000 restwert = 10000 over 3 years
|
||||||
|
expect(jahresAfa(12000, 2000, 3)).toBe(3333.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle different depreciation periods", () => {
|
||||||
|
// 24000 over 8 years = 3000 per year
|
||||||
|
expect(jahresAfa(24000, 0, 8)).toBe(3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("erwerbsjahrAfa", () => {
|
||||||
|
it("should calculate pro-rata depreciation in acquisition year", () => {
|
||||||
|
// Acquired March 15 = 10 months remaining (Mar-Dec, inclusive) = 10/12
|
||||||
|
// 4000 * (10/12) = 3333.33
|
||||||
|
const acqDate = new Date("2024-03-15");
|
||||||
|
const result = erwerbsjahrAfa(12000, 0, 3, acqDate);
|
||||||
|
expect(result).toBe(3333.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate full year if acquired in January", () => {
|
||||||
|
const acqDate = new Date("2024-01-01");
|
||||||
|
const result = erwerbsjahrAfa(12000, 0, 3, acqDate);
|
||||||
|
expect(result).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate 1/12 if acquired in December", () => {
|
||||||
|
const acqDate = new Date("2024-12-01");
|
||||||
|
const result = erwerbsjahrAfa(12000, 0, 3, acqDate);
|
||||||
|
expect(result).toBe(333.33);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("afaFuerJahr", () => {
|
||||||
|
it("should return 0 for years before acquisition", () => {
|
||||||
|
expect(afaFuerJahr(sampleAsset, 2023)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return pro-rata for acquisition year", () => {
|
||||||
|
// 2024 acquisition, 10 months = 3333.33
|
||||||
|
const result = afaFuerJahr(sampleAsset, 2024);
|
||||||
|
expect(result).toBe(3333.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return full annual amount for middle years", () => {
|
||||||
|
expect(afaFuerJahr(sampleAsset, 2025)).toBe(4000);
|
||||||
|
expect(afaFuerJahr(sampleAsset, 2026)).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0 after full depreciation period", () => {
|
||||||
|
// 3 years: 2024, 2025, 2026 -> after 2026 = 0
|
||||||
|
expect(afaFuerJahr(sampleAsset, 2027)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle asset with residual value", () => {
|
||||||
|
const assetWithRestwert: AnlagegutRaw = {
|
||||||
|
anschaffungskosten: 5000,
|
||||||
|
nutzungsdauerJahre: 5,
|
||||||
|
restwert: 500,
|
||||||
|
anschaffungsdatum: "2024-01-01",
|
||||||
|
aktiv: true,
|
||||||
|
};
|
||||||
|
// (5000-500) / 5 = 900 per year
|
||||||
|
expect(afaFuerJahr(assetWithRestwert, 2024)).toBe(900);
|
||||||
|
expect(afaFuerJahr(assetWithRestwert, 2028)).toBe(900);
|
||||||
|
expect(afaFuerJahr(assetWithRestwert, 2029)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("kumulierteAfa", () => {
|
||||||
|
it("should calculate cumulative depreciation correctly", () => {
|
||||||
|
// 2024: 3333.33, 2025: 4000, 2026: 4000 = 11333.33
|
||||||
|
expect(kumulierteAfa(sampleAsset, 2026)).toBe(11333.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0 for year before acquisition", () => {
|
||||||
|
expect(kumulierteAfa(sampleAsset, 2023)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return full depreciation after end of period", () => {
|
||||||
|
// After 3 years: 3333.33 + 4000 + 4000 = 11333.33
|
||||||
|
// But asset cost is 12000, so 12000 - 11333.33 = 666.67 remaining
|
||||||
|
const result = kumulierteAfa(sampleAsset, 2027);
|
||||||
|
expect(result).toBe(11333.33);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buchwert", () => {
|
||||||
|
it("should calculate book value correctly", () => {
|
||||||
|
// 2024: 3333.33 depreciation -> 12000 - 3333.33 = 8666.67
|
||||||
|
expect(buchwert(sampleAsset, 2024)).toBe(8666.67);
|
||||||
|
// 2025: another 4000 -> 8666.67 - 4000 = 4666.67
|
||||||
|
expect(buchwert(sampleAsset, 2025)).toBe(4666.67);
|
||||||
|
// 2026: another 4000 -> 4666.67 - 4000 = 666.67
|
||||||
|
expect(buchwert(sampleAsset, 2026)).toBe(666.67);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not go below residual value", () => {
|
||||||
|
const assetWithRestwert: AnlagegutRaw = {
|
||||||
|
anschaffungskosten: 5000,
|
||||||
|
nutzungsdauerJahre: 5,
|
||||||
|
restwert: 500,
|
||||||
|
anschaffungsdatum: "2024-01-01",
|
||||||
|
aktiv: true,
|
||||||
|
};
|
||||||
|
// After full depreciation: 5000 - (900 * 5) = 500
|
||||||
|
expect(buchwert(assetWithRestwert, 2028)).toBe(500);
|
||||||
|
expect(buchwert(assetWithRestwert, 2030)).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return acquisition cost for year before acquisition", () => {
|
||||||
|
expect(buchwert(sampleAsset, 2023)).toBe(12000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assetStatus", () => {
|
||||||
|
it("should return 'inaktiv' for inactive assets", () => {
|
||||||
|
const inactive: AnlagegutRaw = { ...sampleAsset, aktiv: false };
|
||||||
|
expect(assetStatus(inactive, 2025)).toBe("inaktiv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 'aktiv' for active assets within depreciation period", () => {
|
||||||
|
expect(assetStatus(sampleAsset, 2024)).toBe("aktiv");
|
||||||
|
expect(assetStatus(sampleAsset, 2025)).toBe("aktiv");
|
||||||
|
expect(assetStatus(sampleAsset, 2026)).toBe("aktiv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 'vollständig abgeschrieben' after depreciation period", () => {
|
||||||
|
expect(assetStatus(sampleAsset, 2027)).toBe("vollständig abgeschrieben");
|
||||||
|
expect(assetStatus(sampleAsset, 2030)).toBe("vollständig abgeschrieben");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("Buchungen - Double-Entry Bookkeeping Logic", () => {
|
||||||
|
describe("TransactionAccount Enum", () => {
|
||||||
|
it("should have KASSE and BANK accounts", () => {
|
||||||
|
// These are the two main accounts for German bookkeeping
|
||||||
|
const accounts = ["KASSE", "BANK"];
|
||||||
|
expect(accounts).toContain("KASSE");
|
||||||
|
expect(accounts).toContain("BANK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TransactionType Enum", () => {
|
||||||
|
it("should have EINLAGE and ENTNAHME types", () => {
|
||||||
|
// EINLAGE = Owner investment, ENTNAHME = Owner withdrawal
|
||||||
|
const types = ["EINLAGE", "ENTNAHME"];
|
||||||
|
expect(types).toContain("EINLAGE");
|
||||||
|
expect(types).toContain("ENTNAHME");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Zahlungsart Enum", () => {
|
||||||
|
it("should have KASSE and BANK payment methods", () => {
|
||||||
|
const paymentMethods = ["KASSE", "BANK"];
|
||||||
|
expect(paymentMethods).toContain("KASSE");
|
||||||
|
expect(paymentMethods).toContain("BANK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Buchung Business Logic", () => {
|
||||||
|
it("should calculate correct sign for EINLAGE (positive)", () => {
|
||||||
|
// EINLAGE increases the company's assets
|
||||||
|
const amount = 1000;
|
||||||
|
const type: "EINLAGE" | "ENTNAHME" = "EINLAGE";
|
||||||
|
|
||||||
|
// In German bookkeeping: EINLAGE is recorded as positive (credit to equity)
|
||||||
|
const signedAmount = type === "EINLAGE" ? amount : -amount;
|
||||||
|
expect(signedAmount).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate correct sign for ENTNAHME (negative)", () => {
|
||||||
|
// ENTNAHME decreases the company's assets
|
||||||
|
const amount = 500;
|
||||||
|
const type: "EINLAGE" | "ENTNAHME" = "ENTNAHME";
|
||||||
|
|
||||||
|
// In German bookkeeping: ENTNAHME is recorded as negative (debit to equity)
|
||||||
|
const signedAmount = type === "EINLAGE" ? amount : -amount;
|
||||||
|
expect(signedAmount).toBe(-500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should identify business records correctly", () => {
|
||||||
|
// Business records (isBusinessRecord = true) come from Einnahmen/Ausgaben
|
||||||
|
const isBusinessRecord = true;
|
||||||
|
const hasKategorie = true;
|
||||||
|
|
||||||
|
expect(isBusinessRecord).toBe(true);
|
||||||
|
expect(hasKategorie).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-business records (private)", () => {
|
||||||
|
// Non-business records might not have a kategorie
|
||||||
|
const isBusinessRecord = false;
|
||||||
|
const kategorie = null;
|
||||||
|
|
||||||
|
expect(isBusinessRecord).toBe(false);
|
||||||
|
expect(kategorie).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate Decimal precision for amounts", () => {
|
||||||
|
// Prisma Decimal(10,2) - max 10 digits, 2 decimal places
|
||||||
|
const amount = 12345678.90; // 8 digits before decimal, 2 after
|
||||||
|
const maxAmount = 99999999.99; // Max for DECIMAL(10,2)
|
||||||
|
|
||||||
|
expect(amount).toBeLessThanOrEqual(maxAmount);
|
||||||
|
expect(Number(amount.toFixed(2))).toBe(12345678.9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Buchung Link Logic (Linked Transactions)", () => {
|
||||||
|
it("should allow linking related transactions", () => {
|
||||||
|
// Example: An invoice payment might be linked to the invoice
|
||||||
|
const buchungId = "buchung-123";
|
||||||
|
const linkedBuchungId = "buchung-456";
|
||||||
|
|
||||||
|
// A Buchung can be linked to another (e.g., invoice payment -> invoice)
|
||||||
|
const link = { source: buchungId, target: linkedBuchungId };
|
||||||
|
expect(link.source).toBe("buchung-123");
|
||||||
|
expect(link.target).toBe("buchung-456");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kategorie Logic", () => {
|
||||||
|
it("should have unique category names per company", () => {
|
||||||
|
// BuchungKategorie has @@unique([companyId, name, typ])
|
||||||
|
const companyId = "company-123";
|
||||||
|
const categories = [
|
||||||
|
{ companyId, name: "Fußpflege", typ: "EINNAHME" },
|
||||||
|
{ companyId, name: "Miete", typ: "AUSGABE" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const uniqueCheck = new Set(categories.map(c => `${c.companyId}-${c.name}-${c.typ}`));
|
||||||
|
expect(uniqueCheck.size).toBe(categories.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should distinguish between EINNAHME and AUSGABE", () => {
|
||||||
|
const einnahme: "EINNAHME" = "EINNAHME";
|
||||||
|
const ausgabe: "AUSGABE" = "AUSGABE";
|
||||||
|
|
||||||
|
expect(einnahme).toBe("EINNAHME");
|
||||||
|
expect(ausgabe).toBe("AUSGABE");
|
||||||
|
expect(einnahme).not.toBe(ausgabe);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Date-Based Queries", () => {
|
||||||
|
it("should filter Buchungen by date range", () => {
|
||||||
|
const buchungen = [
|
||||||
|
{ date: new Date("2026-01-15"), amount: 100 },
|
||||||
|
{ date: new Date("2026-02-20"), amount: 200 },
|
||||||
|
{ date: new Date("2026-03-10"), amount: 300 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const startDate = new Date("2026-02-01");
|
||||||
|
const endDate = new Date("2026-03-31");
|
||||||
|
|
||||||
|
const filtered = buchungen.filter(b =>
|
||||||
|
b.date >= startDate && b.date <= endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(filtered).toHaveLength(2);
|
||||||
|
expect(filtered[0].amount).toBe(200);
|
||||||
|
expect(filtered[1].amount).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should group Buchungen by month for reports", () => {
|
||||||
|
const buchungen = [
|
||||||
|
{ date: new Date("2026-01-10"), amount: 100 },
|
||||||
|
{ date: new Date("2026-01-20"), amount: 150 },
|
||||||
|
{ date: new Date("2026-02-05"), amount: 200 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const grouped = buchungen.reduce((acc, b) => {
|
||||||
|
const month = b.date.getMonth(); // 0-indexed
|
||||||
|
acc[month] = (acc[month] || 0) + b.amount;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<number, number>);
|
||||||
|
|
||||||
|
expect(grouped[0]).toBe(250); // January (month 0)
|
||||||
|
expect(grouped[1]).toBe(200); // February (month 1)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
isDebugMode,
|
||||||
|
setDebugMode,
|
||||||
|
validateTaxId,
|
||||||
|
validateVatId,
|
||||||
|
validateIban,
|
||||||
|
validateBic,
|
||||||
|
validateWebsite,
|
||||||
|
validateCompanyForm,
|
||||||
|
getFieldError,
|
||||||
|
hasFieldError,
|
||||||
|
type ValidationError,
|
||||||
|
type CompanyFormData,
|
||||||
|
} from "@/lib/client-validation";
|
||||||
|
|
||||||
|
describe("client-validation.ts", () => {
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {};
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store[key] || null,
|
||||||
|
setItem: (key: string, value: string) => { store[key] = value; },
|
||||||
|
removeItem: (key: string) => { delete store[key]; },
|
||||||
|
clear: () => { store = {}; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset localStorage mock
|
||||||
|
localStorageMock.clear();
|
||||||
|
(global as any).localStorage = localStorageMock;
|
||||||
|
(global as any).window = { localStorage: localStorageMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(global as any).window = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Debug Mode", () => {
|
||||||
|
it("should be disabled by default (no window)", () => {
|
||||||
|
(global as any).window = undefined;
|
||||||
|
expect(isDebugMode()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect debug mode from localStorage", () => {
|
||||||
|
setDebugMode(true);
|
||||||
|
expect(isDebugMode()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should disable debug mode", () => {
|
||||||
|
setDebugMode(true);
|
||||||
|
expect(isDebugMode()).toBe(true);
|
||||||
|
|
||||||
|
setDebugMode(false);
|
||||||
|
expect(isDebugMode()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateTaxId", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateTaxId("")).toBeNull();
|
||||||
|
expect(validateTaxId(null)).toBeNull();
|
||||||
|
expect(validateTaxId(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct 10-digit tax ID", () => {
|
||||||
|
expect(validateTaxId("1234567890")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid tax IDs", () => {
|
||||||
|
const result = validateTaxId("123"); // Too short
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("taxId");
|
||||||
|
expect(result?.message).toContain("10 Ziffern");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject tax ID with letters", () => {
|
||||||
|
const result = validateTaxId("abc4567890");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateVatId", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateVatId("")).toBeNull();
|
||||||
|
expect(validateVatId(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct DE VAT ID", () => {
|
||||||
|
expect(validateVatId("DE123456789")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject VAT ID without DE prefix", () => {
|
||||||
|
const result = validateVatId("1234567890");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("vatId");
|
||||||
|
expect(result?.message).toContain("DE + 9 Ziffern");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject VAT ID with wrong length", () => {
|
||||||
|
const result = validateVatId("DE12345"); // Too short
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateIban", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateIban("")).toBeNull();
|
||||||
|
expect(validateIban(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct IBAN", () => {
|
||||||
|
expect(validateIban("DE89370400440532013000")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid IBAN", () => {
|
||||||
|
const result = validateIban("INVALID");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("bankIban");
|
||||||
|
expect(result?.message).toContain("IBAN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateBic", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateBic("")).toBeNull();
|
||||||
|
expect(validateBic(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct BIC", () => {
|
||||||
|
expect(validateBic("DEUTDEFF")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid BIC", () => {
|
||||||
|
const result = validateBic("123"); // Too short
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("bankBic");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateWebsite", () => {
|
||||||
|
it("should return null for empty/null values", () => {
|
||||||
|
expect(validateWebsite("")).toBeNull();
|
||||||
|
expect(validateWebsite(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate correct website URLs", () => {
|
||||||
|
expect(validateWebsite("https://example.com")).toBeNull();
|
||||||
|
expect(validateWebsite("http://example.com")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject website without protocol", () => {
|
||||||
|
const result = validateWebsite("example.com");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.field).toBe("website");
|
||||||
|
expect(result?.message).toContain("http:// oder https://");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject too long website URLs", () => {
|
||||||
|
const longUrl = "https://" + "a".repeat(250);
|
||||||
|
const result = validateWebsite(longUrl);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.message).toContain("255");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateCompanyForm", () => {
|
||||||
|
const validFormData: CompanyFormData = {
|
||||||
|
name: "Test GmbH",
|
||||||
|
address: "Hauptstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should pass validation for valid data", () => {
|
||||||
|
const errors = validateCompanyForm(validFormData);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require name", () => {
|
||||||
|
const data = { ...validFormData, name: "" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
expect(errors[0].field).toBe("name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require address", () => {
|
||||||
|
const data = { ...validFormData, address: "" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const addressError = errors.find(e => e.field === "address");
|
||||||
|
expect(addressError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require zip", () => {
|
||||||
|
const data = { ...validFormData, zip: "" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const zipError = errors.find(e => e.field === "zip");
|
||||||
|
expect(zipError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require city", () => {
|
||||||
|
const data = { ...validFormData, city: "" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const cityError = errors.find(e => e.field === "city");
|
||||||
|
expect(cityError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate zip format (digits only)", () => {
|
||||||
|
const data = { ...validFormData, zip: "abc" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const zipError = errors.find(e => e.field === "zip");
|
||||||
|
expect(zipError).toBeDefined();
|
||||||
|
expect(zipError?.message).toContain("Zahlen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate optional fields when provided", () => {
|
||||||
|
const data = {
|
||||||
|
...validFormData,
|
||||||
|
taxId: "123", // Invalid
|
||||||
|
vatId: "INVALID", // Invalid
|
||||||
|
website: "example.com", // Invalid (no protocol)
|
||||||
|
};
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
expect(errors.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept valid optional fields", () => {
|
||||||
|
const data = {
|
||||||
|
...validFormData,
|
||||||
|
taxId: "1234567890",
|
||||||
|
vatId: "DE123456789",
|
||||||
|
website: "https://example.com",
|
||||||
|
};
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const hasTaxIdError = errors.some(e => e.field === "taxId");
|
||||||
|
const hasVatIdError = errors.some(e => e.field === "vatId");
|
||||||
|
const hasWebsiteError = errors.some(e => e.field === "website");
|
||||||
|
expect(hasTaxIdError).toBe(false);
|
||||||
|
expect(hasVatIdError).toBe(false);
|
||||||
|
expect(hasWebsiteError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate email format", () => {
|
||||||
|
const data = { ...validFormData, email: "invalid-email" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const emailError = errors.find(e => e.field === "email");
|
||||||
|
expect(emailError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept valid email", () => {
|
||||||
|
const data = { ...validFormData, email: "test@example.com" };
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
const emailError = errors.find(e => e.field === "email");
|
||||||
|
expect(emailError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate field length limits", () => {
|
||||||
|
const data = {
|
||||||
|
...validFormData,
|
||||||
|
name: "a".repeat(300), // Too long
|
||||||
|
phone: "a".repeat(30), // Too long
|
||||||
|
};
|
||||||
|
const errors = validateCompanyForm(data);
|
||||||
|
expect(errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFieldError", () => {
|
||||||
|
it("should return error message for field", () => {
|
||||||
|
const errors: ValidationError[] = [
|
||||||
|
{ field: "name", message: "Name required" },
|
||||||
|
{ field: "email", message: "Invalid email" },
|
||||||
|
];
|
||||||
|
expect(getFieldError("name", errors)).toBe("Name required");
|
||||||
|
expect(getFieldError("email", errors)).toBe("Invalid email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for field without error", () => {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
expect(getFieldError("name", errors)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasFieldError", () => {
|
||||||
|
it("should return true if field has error", () => {
|
||||||
|
const errors: ValidationError[] = [
|
||||||
|
{ field: "name", message: "Name required" },
|
||||||
|
];
|
||||||
|
expect(hasFieldError("name", errors)).toBe(true);
|
||||||
|
expect(hasFieldError("email", errors)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for empty errors", () => {
|
||||||
|
expect(hasFieldError("name", [])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
EINNAHME_KATEGORIEN,
|
||||||
|
EINNAHME_LABELS,
|
||||||
|
type EinnahmeKategorieKey,
|
||||||
|
} from "@/lib/einnahmen";
|
||||||
|
import {
|
||||||
|
AUSGABE_KATEGORIEN,
|
||||||
|
KATEGORIE_LABELS,
|
||||||
|
type AusgabeKategorieKey,
|
||||||
|
} from "@/lib/ausgaben";
|
||||||
|
|
||||||
|
describe("einnahmen.ts - Revenue Categories", () => {
|
||||||
|
it("should have all expected categories", () => {
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("FUSSPFLEGE");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("PRIVATEINLAGEN");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("DARLEHEN");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("STEUERERSTATTUNGEN");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("ZINSERTRAEGE");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("VERMIETUNG_VERPACHTUNG");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("VERAEUSSERUNGSERLOES");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("EIGENVERBRAUCH");
|
||||||
|
expect(EINNAHME_KATEGORIEN).toContain("SONSTIGE_EINNAHMEN");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have 10 revenue categories", () => {
|
||||||
|
expect(EINNAHME_KATEGORIEN).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have labels for all categories", () => {
|
||||||
|
EINNAHME_KATEGORIEN.forEach((key) => {
|
||||||
|
expect(EINNAHME_LABELS[key as EinnahmeKategorieKey]).toBeDefined();
|
||||||
|
expect(typeof EINNAHME_LABELS[key as EinnahmeKategorieKey]).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct labels", () => {
|
||||||
|
expect(EINNAHME_LABELS.FUSSPFLEGE).toBe("Fußpflege/Verkauf/Gutscheine");
|
||||||
|
expect(EINNAHME_LABELS.PRIVATEINLAGEN).toBe("Privateinlagen");
|
||||||
|
expect(EINNAHME_LABELS.DARLEHEN).toBe("Darlehen");
|
||||||
|
expect(EINNAHME_LABELS.ZINSERTRAEGE).toBe("Zinserträge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have valid TypeScript types", () => {
|
||||||
|
const testKey: EinnahmeKategorieKey = "FUSSPFLEGE";
|
||||||
|
expect(testKey).toBe("FUSSPFLEGE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ausgaben.ts - Expense Categories", () => {
|
||||||
|
it("should have all expected categories", () => {
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("WAREN_ROHSTOFFE");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("GERINGWERTIGE_WIRTSCHAFTSGUETER");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("ABSCHREIBUNGEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("MIETE");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("STROM_WASSER");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("TELEKOMMUNIKATION");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("FORTBILDUNG_MESSEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("BEITRAEGE");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("VERSICHERUNGEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("WERBEKOSTEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("ZINSEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("REISEKOSTEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("REPARATUREN_INSTANDHALTUNG");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("BUEROBEDARF");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("REPRAESENTATIONSKOSTEN");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("SONSTIGER_BETRIEBSBEDARF");
|
||||||
|
expect(AUSGABE_KATEGORIEN).toContain("NEBENKOSTEN_GELDVERKEHR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have 17 expense categories", () => {
|
||||||
|
expect(AUSGABE_KATEGORIEN).toHaveLength(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have labels for all categories", () => {
|
||||||
|
AUSGABE_KATEGORIEN.forEach((key) => {
|
||||||
|
expect(KATEGORIE_LABELS[key as AusgabeKategorieKey]).toBeDefined();
|
||||||
|
expect(typeof KATEGORIE_LABELS[key as AusgabeKategorieKey]).toBe("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct labels", () => {
|
||||||
|
expect(KATEGORIE_LABELS.WAREN_ROHSTOFFE).toBe("Waren, Rohstoffe, Hilfsstoffe");
|
||||||
|
expect(KATEGORIE_LABELS.GERINGWERTIGE_WIRTSCHAFTSGUETER).toBe(
|
||||||
|
"Geringwertige Wirtschaftsgüter"
|
||||||
|
);
|
||||||
|
expect(KATEGORIE_LABELS.MIETE).toBe("Miete");
|
||||||
|
expect(KATEGORIE_LABELS.ZINSEN).toBe("Zinsen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have valid TypeScript types", () => {
|
||||||
|
const testKey: AusgabeKategorieKey = "MIETE";
|
||||||
|
expect(testKey).toBe("MIETE");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Category Integration", () => {
|
||||||
|
it("should not have overlapping keys between revenue and expenses", () => {
|
||||||
|
const overlap = EINNAHME_KATEGORIEN.filter((key) =>
|
||||||
|
AUSGABE_KATEGORIEN.includes(key as any)
|
||||||
|
);
|
||||||
|
expect(overlap).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have consistent naming convention (uppercase with underscores)", () => {
|
||||||
|
const validPattern = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
||||||
|
|
||||||
|
EINNAHME_KATEGORIEN.forEach((key) => {
|
||||||
|
expect(key).toMatch(validPattern);
|
||||||
|
});
|
||||||
|
|
||||||
|
AUSGABE_KATEGORIEN.forEach((key) => {
|
||||||
|
expect(key).toMatch(validPattern);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import prisma from "@/lib/prisma.server";
|
||||||
|
import { generateInvoiceNumber } from "@/lib/invoice-number.server";
|
||||||
|
|
||||||
|
// Mock the Prisma client
|
||||||
|
vi.mock("@/lib/prisma.server", () => ({
|
||||||
|
default: {
|
||||||
|
company: {
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("invoice-number.server.ts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate invoice number with year and sequence", async () => {
|
||||||
|
const mockCompany = {
|
||||||
|
invoicePrefix: "RE",
|
||||||
|
invoiceSequence: 5, // Already incremented by Prisma
|
||||||
|
};
|
||||||
|
|
||||||
|
(prisma.company.update as any).mockResolvedValue(mockCompany);
|
||||||
|
|
||||||
|
const result = await generateInvoiceNumber("company-123");
|
||||||
|
|
||||||
|
// invoiceSequence is already 5 (after increment), so we expect 005
|
||||||
|
expect(result).toBe("RE-2026-005");
|
||||||
|
expect(prisma.company.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: "company-123" },
|
||||||
|
data: { invoiceSequence: { increment: 1 } },
|
||||||
|
select: { invoicePrefix: true, invoiceSequence: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pad sequence with zeros", async () => {
|
||||||
|
const mockCompany = {
|
||||||
|
invoicePrefix: "RG",
|
||||||
|
invoiceSequence: 2, // Already incremented by Prisma
|
||||||
|
};
|
||||||
|
|
||||||
|
(prisma.company.update as any).mockResolvedValue(mockCompany);
|
||||||
|
|
||||||
|
const result = await generateInvoiceNumber("company-456");
|
||||||
|
|
||||||
|
expect(result).toBe("RG-2026-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom prefix", async () => {
|
||||||
|
const mockCompany = {
|
||||||
|
invoicePrefix: "INV",
|
||||||
|
invoiceSequence: 10, // Already incremented by Prisma
|
||||||
|
};
|
||||||
|
|
||||||
|
(prisma.company.update as any).mockResolvedValue(mockCompany);
|
||||||
|
|
||||||
|
const result = await generateInvoiceNumber("company-789");
|
||||||
|
|
||||||
|
expect(result).toBe("INV-2026-010");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_AUSGABE_KATEGORIEN,
|
||||||
|
DEFAULT_EINNAHME_KATEGORIEN,
|
||||||
|
} from "@/lib/kategorie-defaults";
|
||||||
|
|
||||||
|
describe("kategorie-defaults.ts", () => {
|
||||||
|
describe("DEFAULT_AUSGABE_KATEGORIEN", () => {
|
||||||
|
it("should have 17 default expense categories", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toHaveLength(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include common expense categories", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Waren, Rohstoffe, Hilfsstoffe");
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Miete");
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Strom, Wasser");
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Telekommunikationskosten");
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Bürobedarf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include GERINGWERTIGE_WIRTSCHAFTSGÜTER", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Geringwertige Wirtschaftsgüter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Abschreibungen", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Abschreibungen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Zinsen", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Zinsen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Reisekosten", () => {
|
||||||
|
expect(DEFAULT_AUSGABE_KATEGORIEN).toContain("Reisekosten");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have unique values", () => {
|
||||||
|
const unique = new Set(DEFAULT_AUSGABE_KATEGORIEN);
|
||||||
|
expect(unique.size).toBe(DEFAULT_AUSGABE_KATEGORIEN.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not have empty strings", () => {
|
||||||
|
DEFAULT_AUSGABE_KATEGORIEN.forEach((cat) => {
|
||||||
|
expect(cat).not.toBe("");
|
||||||
|
expect(cat.trim().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DEFAULT_EINNAHME_KATEGORIEN", () => {
|
||||||
|
it("should have 10 default revenue categories", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Fußpflege category", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Fußpflege/Verkauf/Gutscheine");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Privateinlagen", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Privateinlagen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Darlehen", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Darlehen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Steuererstattungen", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Steuererstattungen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Zinserträge", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Zinserträge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Miet-/Pachteinnahmen", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Miet-/Pachteinnahmen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include Veräußerungserlöse", () => {
|
||||||
|
expect(DEFAULT_EINNAHME_KATEGORIEN).toContain("Veräußerungserlöse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have unique values", () => {
|
||||||
|
const unique = new Set(DEFAULT_EINNAHME_KATEGORIEN);
|
||||||
|
expect(unique.size).toBe(DEFAULT_EINNAHME_KATEGORIEN.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not have empty strings", () => {
|
||||||
|
DEFAULT_EINNAHME_KATEGORIEN.forEach((cat) => {
|
||||||
|
expect(cat).not.toBe("");
|
||||||
|
expect(cat.trim().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Category Integration", () => {
|
||||||
|
it("should not have overlapping categories between income and expenses", () => {
|
||||||
|
const overlap = DEFAULT_AUSGABE_KATEGORIEN.filter((cat) =>
|
||||||
|
DEFAULT_EINNAHME_KATEGORIEN.includes(cat)
|
||||||
|
);
|
||||||
|
expect(overlap).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have consistent German language", () => {
|
||||||
|
// Check that categories contain German umlauts or common German words
|
||||||
|
const allCategories = [
|
||||||
|
...DEFAULT_AUSGABE_KATEGORIEN,
|
||||||
|
...DEFAULT_EINNAHME_KATEGORIEN,
|
||||||
|
];
|
||||||
|
|
||||||
|
allCategories.forEach((cat) => {
|
||||||
|
expect(cat.length).toBeGreaterThan(0);
|
||||||
|
// Should not contain numbers at start (heuristic check)
|
||||||
|
expect(/^\d/.test(cat)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should cover common business categories", () => {
|
||||||
|
// Expenses should cover: rent, utilities, telecom, office supplies
|
||||||
|
const expenseStr = DEFAULT_AUSGABE_KATEGORIEN.join(" ");
|
||||||
|
expect(expenseStr).toContain("Miete");
|
||||||
|
expect(expenseStr).toContain("Bürobedarf");
|
||||||
|
|
||||||
|
// Revenue should cover: services, loans, interest
|
||||||
|
const revenueStr = DEFAULT_EINNAHME_KATEGORIEN.join(" ");
|
||||||
|
expect(revenueStr).toContain("Darlehen");
|
||||||
|
expect(revenueStr).toContain("Zinserträge");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Practical Usage", () => {
|
||||||
|
it("should be usable as default values for new companies", () => {
|
||||||
|
// Simulate creating default categories for a new company
|
||||||
|
const companyId = "new-company-123";
|
||||||
|
|
||||||
|
const defaultExpenseCategories = DEFAULT_AUSGABE_KATEGORIEN.map((name) => ({
|
||||||
|
companyId,
|
||||||
|
name,
|
||||||
|
typ: "AUSGABE" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultRevenueCategories = DEFAULT_EINNAHME_KATEGORIEN.map((name) => ({
|
||||||
|
companyId,
|
||||||
|
name,
|
||||||
|
typ: "EINNAHME" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(defaultExpenseCategories).toHaveLength(17);
|
||||||
|
expect(defaultRevenueCategories).toHaveLength(10);
|
||||||
|
expect(defaultExpenseCategories[0].typ).toBe("AUSGABE");
|
||||||
|
expect(defaultRevenueCategories[0].typ).toBe("EINNAHME");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow adding custom expense categories", () => {
|
||||||
|
const customCategories: string[] = [
|
||||||
|
...DEFAULT_AUSGABE_KATEGORIEN,
|
||||||
|
"Custom Expense Category",
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(customCategories.length).toBe(18); // 17 defaults + 1 custom
|
||||||
|
expect(customCategories[customCategories.length - 1]).toBe("Custom Expense Category");
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
currencySchema,
|
||||||
|
taxRateSchema,
|
||||||
|
ibanSchema,
|
||||||
|
taxIdSchema,
|
||||||
|
vatIdSchema,
|
||||||
|
invoiceSchema,
|
||||||
|
companySchema,
|
||||||
|
customerSchema,
|
||||||
|
} from "@/lib/schemas";
|
||||||
|
|
||||||
|
describe("schemas.ts - Zod Validation", () => {
|
||||||
|
describe("currencySchema", () => {
|
||||||
|
it("should accept valid currency values", () => {
|
||||||
|
expect(currencySchema.parse(0)).toBe(0);
|
||||||
|
expect(currencySchema.parse(100)).toBe(100);
|
||||||
|
expect(currencySchema.parse(99.99)).toBe(99.99);
|
||||||
|
expect(currencySchema.parse(1000.5)).toBe(1000.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject negative values", () => {
|
||||||
|
expect(() => currencySchema.parse(-1)).toThrow("Geldbeträge dürfen nicht negativ sein");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject more than 2 decimal places", () => {
|
||||||
|
expect(() => currencySchema.parse(1.999)).toThrow(
|
||||||
|
"Geldbeträge dürfen maximal 2 Dezimalstellen haben"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept exactly 2 decimal places", () => {
|
||||||
|
expect(currencySchema.parse(1.99)).toBe(1.99);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("taxRateSchema", () => {
|
||||||
|
it("should accept valid German tax rates", () => {
|
||||||
|
expect(taxRateSchema.parse(0)).toBe(0);
|
||||||
|
expect(taxRateSchema.parse(7)).toBe(7);
|
||||||
|
expect(taxRateSchema.parse(19)).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid tax rates", () => {
|
||||||
|
expect(() => taxRateSchema.parse(5)).toThrow(
|
||||||
|
"Steuersatz muss 0 (steuerfrei), 7 (ermäßigt) oder 19 (Standard) sein"
|
||||||
|
);
|
||||||
|
expect(() => taxRateSchema.parse(20)).toThrow();
|
||||||
|
expect(() => taxRateSchema.parse(15)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject non-integer values", () => {
|
||||||
|
expect(() => taxRateSchema.parse(7.5)).toThrow("Steuersatz muss eine ganze Zahl sein");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ibanSchema", () => {
|
||||||
|
it("should accept valid IBANs", () => {
|
||||||
|
expect(ibanSchema.parse("DE89370400440532013000")).toBe("DE89370400440532013000");
|
||||||
|
expect(ibanSchema.parse("AT611904300234573201")).toBe("AT611904300234573201");
|
||||||
|
expect(ibanSchema.parse("CH9300762011623852957")).toBe("CH9300762011623852957");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept empty string", () => {
|
||||||
|
expect(ibanSchema.parse("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid IBAN format", () => {
|
||||||
|
// No country code - starts with numbers
|
||||||
|
expect(() => ibanSchema.parse("1234567890")).toThrow();
|
||||||
|
// Too short - only 4 chars (need at least 5: 2 letters + 2 digits + 1)
|
||||||
|
expect(() => ibanSchema.parse("DE1")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("taxIdSchema", () => {
|
||||||
|
it("should accept valid 10-digit tax IDs", () => {
|
||||||
|
expect(taxIdSchema.parse("1234567890")).toBe("1234567890");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept empty string", () => {
|
||||||
|
expect(taxIdSchema.parse("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid tax IDs", () => {
|
||||||
|
expect(() => taxIdSchema.parse("123")).toThrow("Steuernummer muss 10 Ziffern haben");
|
||||||
|
expect(() => taxIdSchema.parse("12345678901")).toThrow();
|
||||||
|
expect(() => taxIdSchema.parse("abcdefghij")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vatIdSchema", () => {
|
||||||
|
it("should accept valid DE VAT IDs", () => {
|
||||||
|
expect(vatIdSchema.parse("DE123456789")).toBe("DE123456789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept empty string", () => {
|
||||||
|
expect(vatIdSchema.parse("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid VAT IDs", () => {
|
||||||
|
expect(() => vatIdSchema.parse("DE123")).toThrow(
|
||||||
|
"USt-IdNr. muss im Format DE + 9 Ziffern sein"
|
||||||
|
);
|
||||||
|
expect(() => vatIdSchema.parse("1234567890")).toThrow();
|
||||||
|
expect(() => vatIdSchema.parse("DE12345678")).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invoiceSchema", () => {
|
||||||
|
const validInvoice = {
|
||||||
|
companyId: "cm123abc",
|
||||||
|
customerId: "cm456def",
|
||||||
|
issueDate: "2026-05-08",
|
||||||
|
dueDate: "2026-06-08",
|
||||||
|
kleinunternehmer: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
description: "Web Development",
|
||||||
|
quantity: 10,
|
||||||
|
unit: "Stunden",
|
||||||
|
unitPrice: 100,
|
||||||
|
taxRate: 19,
|
||||||
|
netAmount: 1000,
|
||||||
|
taxAmount: 190,
|
||||||
|
grossAmount: 1190,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
netTotal: 1000,
|
||||||
|
taxTotal: 190,
|
||||||
|
grossTotal: 1190,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should accept valid invoice", () => {
|
||||||
|
const result = invoiceSchema.parse(validInvoice);
|
||||||
|
expect(result.companyId).toBe("cm123abc");
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require at least one item", () => {
|
||||||
|
const invalid = { ...validInvoice, items: [] };
|
||||||
|
expect(() => invoiceSchema.parse(invalid)).toThrow(
|
||||||
|
"Mindestens ein Rechnungsposition erforderlich"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept optional deliveryDate", () => {
|
||||||
|
const withDelivery = { ...validInvoice, deliveryDate: "2026-05-07" };
|
||||||
|
expect(() => invoiceSchema.parse(withDelivery)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid dates", () => {
|
||||||
|
const invalid = { ...validInvoice, issueDate: "not-a-date" };
|
||||||
|
expect(() => invoiceSchema.parse(invalid)).toThrow("Ungültiges Datum");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept optional notes with max length", () => {
|
||||||
|
const withNotes = { ...validInvoice, notes: "Test notes" };
|
||||||
|
expect(() => invoiceSchema.parse(withNotes)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("companySchema", () => {
|
||||||
|
const validCompany = {
|
||||||
|
name: "Test GmbH",
|
||||||
|
taxId: null,
|
||||||
|
vatId: null,
|
||||||
|
address: "Hauptstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
invoicePrefix: "RE",
|
||||||
|
kleinunternehmer: false,
|
||||||
|
bankIban: null,
|
||||||
|
// bankBic is optional, can be omitted or empty string
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should accept valid company", () => {
|
||||||
|
const result = companySchema.parse(validCompany);
|
||||||
|
expect(result.name).toBe("Test GmbH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require name and address", () => {
|
||||||
|
const invalid = { ...validCompany, name: "" };
|
||||||
|
expect(() => companySchema.parse(invalid)).toThrow("Firmenname erforderlich");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate email format", () => {
|
||||||
|
const withEmail = { ...validCompany, email: "test@example.com" };
|
||||||
|
expect(() => companySchema.parse(withEmail)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid email", () => {
|
||||||
|
const invalid = { ...validCompany, email: "not-an-email" };
|
||||||
|
expect(() => companySchema.parse(invalid)).toThrow("Ungültige E-Mail-Adresse");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate website starts with http/https", () => {
|
||||||
|
const withWebsite = { ...validCompany, website: "https://example.com" };
|
||||||
|
expect(() => companySchema.parse(withWebsite)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject website without protocol", () => {
|
||||||
|
const invalid = { ...validCompany, website: "example.com" };
|
||||||
|
expect(() => companySchema.parse(invalid)).toThrow(
|
||||||
|
"Website muss mit http:// oder https:// beginnen"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept valid IBAN", () => {
|
||||||
|
const withIban = { ...validCompany, bankIban: "DE89370400440532013000" };
|
||||||
|
expect(() => companySchema.parse(withIban)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("customerSchema", () => {
|
||||||
|
const validCustomer = {
|
||||||
|
companyId: "cm123abc",
|
||||||
|
name: "Max Mustermann",
|
||||||
|
address: "Musterstraße 1",
|
||||||
|
zip: "12345",
|
||||||
|
city: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should accept valid customer", () => {
|
||||||
|
const result = customerSchema.parse(validCustomer);
|
||||||
|
expect(result.name).toBe("Max Mustermann");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require companyId", () => {
|
||||||
|
const invalid = { ...validCustomer, companyId: "" };
|
||||||
|
expect(() => customerSchema.parse(invalid)).toThrow("Mandant erforderlich");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate zip code format", () => {
|
||||||
|
const invalid = { ...validCustomer, zip: "abc" };
|
||||||
|
expect(() => customerSchema.parse(invalid)).toThrow(
|
||||||
|
"PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TAX_RATES,
|
||||||
|
calcItemAmounts,
|
||||||
|
calcItemAmountsKleinunternehmer,
|
||||||
|
calcInvoiceTotals,
|
||||||
|
formatCurrency,
|
||||||
|
formatDate,
|
||||||
|
} from "@/lib/tax";
|
||||||
|
|
||||||
|
describe("tax.ts - German Tax Calculations", () => {
|
||||||
|
describe("TAX_RATES", () => {
|
||||||
|
it("should have correct tax rate values", () => {
|
||||||
|
expect(TAX_RATES).toEqual([
|
||||||
|
{ label: "19% MwSt. (Regelsteuersatz)", value: 19 },
|
||||||
|
{ label: "7% MwSt. (ermäßigt)", value: 7 },
|
||||||
|
{ label: "0% (steuerfrei / §13b UStG)", value: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calcItemAmounts", () => {
|
||||||
|
it("should calculate correct amounts for 19% tax", () => {
|
||||||
|
const result = calcItemAmounts(2, 100, 19);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 200,
|
||||||
|
taxAmount: 38,
|
||||||
|
grossAmount: 238,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate correct amounts for 7% tax", () => {
|
||||||
|
const result = calcItemAmounts(1, 50, 7);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 50,
|
||||||
|
taxAmount: 3.5,
|
||||||
|
grossAmount: 53.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tax-free items (0%)", () => {
|
||||||
|
const result = calcItemAmounts(3, 33.33, 0);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 99.99,
|
||||||
|
taxAmount: 0,
|
||||||
|
grossAmount: 99.99,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle decimal quantities", () => {
|
||||||
|
const result = calcItemAmounts(1.5, 100, 19);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 150,
|
||||||
|
taxAmount: 28.5,
|
||||||
|
grossAmount: 178.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should round to 2 decimal places", () => {
|
||||||
|
const result = calcItemAmounts(1, 33.333, 19);
|
||||||
|
// 33.333 * 19% = 6.333..., rounded to 6.33
|
||||||
|
expect(result.netAmount).toBe(33.33);
|
||||||
|
expect(result.taxAmount).toBe(6.33);
|
||||||
|
expect(result.grossAmount).toBe(39.66);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calcItemAmountsKleinunternehmer", () => {
|
||||||
|
it("should set tax to 0 for Kleinunternehmer", () => {
|
||||||
|
const result = calcItemAmountsKleinunternehmer(2, 100);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netAmount: 200,
|
||||||
|
taxAmount: 0,
|
||||||
|
grossAmount: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should treat gross as net for Kleinunternehmer", () => {
|
||||||
|
const result = calcItemAmountsKleinunternehmer(1, 50);
|
||||||
|
expect(result.netAmount).toBe(50);
|
||||||
|
expect(result.grossAmount).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calcInvoiceTotals", () => {
|
||||||
|
it("should sum up multiple invoice items", () => {
|
||||||
|
const items = [
|
||||||
|
{ netAmount: 100, taxAmount: 19, grossAmount: 119 },
|
||||||
|
{ netAmount: 50, taxAmount: 3.5, grossAmount: 53.5 },
|
||||||
|
];
|
||||||
|
const result = calcInvoiceTotals(items);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netTotal: 150,
|
||||||
|
taxTotal: 22.5,
|
||||||
|
grossTotal: 172.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty items array", () => {
|
||||||
|
const result = calcInvoiceTotals([]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netTotal: 0,
|
||||||
|
taxTotal: 0,
|
||||||
|
grossTotal: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should round totals to 2 decimal places", () => {
|
||||||
|
const items = [
|
||||||
|
{ netAmount: 33.333, taxAmount: 6.333, grossAmount: 39.666 },
|
||||||
|
{ netAmount: 66.666, taxAmount: 12.666, grossAmount: 79.332 },
|
||||||
|
];
|
||||||
|
const result = calcInvoiceTotals(items);
|
||||||
|
// calcInvoiceTotals uses Math.round which rounds 99.999 to 100
|
||||||
|
expect(result.netTotal).toBe(100);
|
||||||
|
expect(result.taxTotal).toBe(19);
|
||||||
|
expect(result.grossTotal).toBe(119);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle Kleinunternehmer invoice (no tax)", () => {
|
||||||
|
const items = [
|
||||||
|
{ netAmount: 100, taxAmount: 0, grossAmount: 100 },
|
||||||
|
{ netAmount: 200, taxAmount: 0, grossAmount: 200 },
|
||||||
|
];
|
||||||
|
const result = calcInvoiceTotals(items);
|
||||||
|
expect(result).toEqual({
|
||||||
|
netTotal: 300,
|
||||||
|
taxTotal: 0,
|
||||||
|
grossTotal: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatCurrency", () => {
|
||||||
|
it("should format number to EUR currency", () => {
|
||||||
|
// Note: Intl.NumberFormat uses non-breaking space (\u00A0) before €
|
||||||
|
expect(formatCurrency(1234.56)).toBe("1.234,56\u00A0€");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle string input", () => {
|
||||||
|
expect(formatCurrency("999.99")).toBe("999,99\u00A0€");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format zero correctly", () => {
|
||||||
|
expect(formatCurrency(0)).toBe("0,00\u00A0€");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatDate", () => {
|
||||||
|
it("should format Date object to German format", () => {
|
||||||
|
const date = new Date("2026-05-08");
|
||||||
|
expect(formatDate(date)).toBe("8.5.2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format date string to German format", () => {
|
||||||
|
expect(formatDate("2026-12-31")).toBe("31.12.2026");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tsconfigPaths()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
include: ["app/lib/**/*.ts", "app/lib/**/*.tsx"],
|
||||||
|
exclude: ["app/lib/**/*.server.ts"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
// Global test setup
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear all mocks before each test
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user