Compare commits

...

10 Commits

Author SHA1 Message Date
hwinkel 1f4005f5d4 fix: streamline Docker image build process by removing unnecessary load and save steps
Build and Push Docker Image / test (push) Successful in 28s
Build and Push Docker Image / build (push) Successful in 1m51s
2026-05-09 15:42:09 +02:00
hwinkel 0547f6a41c fix: downgrade upload and download artifact actions to v3 for compatibility
Build and Push Docker Image / test (push) Successful in 37s
Build and Push Docker Image / build (push) Successful in 3m1s
Build and Push Docker Image / push (push) Failing after 46s
2026-05-09 15:32:37 +02:00
hwinkel 28674fb023 fix: ensure Docker image name is lowercase when saving
Build and Push Docker Image / test (push) Successful in 28s
Build and Push Docker Image / build (push) Failing after 2m8s
Build and Push Docker Image / push (push) Has been skipped
2026-05-09 15:27:39 +02:00
hwinkel b640cfdb74 fix: update Docker image saving command for consistency
Build and Push Docker Image / test (push) Successful in 28s
Build and Push Docker Image / build (push) Failing after 1m54s
Build and Push Docker Image / push (push) Has been skipped
2026-05-09 15:23:14 +02:00
hwinkel d076fba0c0 fix: add no-cache option to Docker build step and update .gitignore for new files
Build and Push Docker Image / test (push) Successful in 28s
Build and Push Docker Image / build (push) Failing after 1m57s
Build and Push Docker Image / push (push) Has been skipped
2026-05-09 15:15:39 +02:00
hwinkel f155a7952a feat: split Gitea workflow into test/build/push jobs
Build and Push Docker Image / test (push) Successful in 30s
Build and Push Docker Image / build (push) Failing after 44s
Build and Push Docker Image / push (push) Has been skipped
2026-05-09 12:32:36 +02:00
hwinkel 0e62f0f80b fix: update default category counts for expenses and revenues in tests
Build and Push Docker Image / build (push) Successful in 2m5s
2026-05-09 12:00:23 +02:00
hwinkel 29619658fc feat: add graphify plugin and documentation
Build and Push Docker Image / build (push) Failing after 1m35s
- Introduced a new graphify OpenCode plugin to remind users about the knowledge graph before executing bash commands.
- Added AGENTS.md for agent guidance, including development commands, environment setup, Docker deployment, code structure, conventions, and testing instructions.
- Created opencode.json to configure the graphify plugin and superpowers.
- Updated tests to improve type safety and added missing imports in test files.
- Added .graphify_uncached.txt to track relevant files for graphify.
2026-05-09 11:52:16 +02:00
hwinkel db953b1e28 Add comprehensive tests for client validation, revenue and expense categories, invoice generation, and schemas
- Implement tests for client validation functions including tax ID, VAT ID, IBAN, BIC, website, and company form validation.
- Create tests for revenue and expense categories ensuring all expected categories and labels are present.
- Add tests for invoice number generation with various scenarios including prefix handling and sequence padding.
- Introduce tests for default categories and their integration, ensuring no overlaps and consistent naming conventions.
- Implement Zod schema validation tests for currency, tax rates, IBAN, tax ID, VAT ID, invoices, companies, and customers.
- Add utility tests for tax calculations, including item amounts and invoice totals, ensuring correct handling of tax rates and formatting.
- Set up Vitest configuration and global test setup for consistent testing environment.
2026-05-08 16:03:05 +02:00
hwinkel 586d5b2cc8 feat: add .continue to .gitignore for improved file management 2026-05-08 15:02:59 +02:00
27 changed files with 29459 additions and 22 deletions
+35 -3
View File
@@ -10,16 +10,47 @@ env:
IMAGE_NAME: ${{ gitea.repository }} IMAGE_NAME: ${{ gitea.repository }}
jobs: jobs:
build: test:
permissions: permissions:
contents: read contents: read
packages: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -39,10 +70,11 @@ jobs:
type=sha,prefix=,format=short type=sha,prefix=,format=short
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
no-cache: true
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
+4
View File
@@ -53,3 +53,7 @@ data/documents/
.vscode/ .vscode/
.react-router .react-router
.continue
.opencode
docs/superpowers
+9489
View File
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
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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
+22
View File
@@ -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;
}
},
};
};
+53
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
".opencode/plugins/graphify.js",
"superpowers@git+https://github.com/obra/superpowers.git"
]
}
+1344 -18
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -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",
@@ -54,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"
} }
} }
+67
View File
@@ -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");
});
});
+198
View File
@@ -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");
});
});
});
+190
View File
@@ -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);
});
});
});
+128
View File
@@ -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
};
}
+159
View File
@@ -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");
});
});
});
+152
View File
@@ -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)
});
});
});
+298
View File
@@ -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);
});
});
});
+116
View File
@@ -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);
});
});
});
+63
View File
@@ -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");
});
});
+165
View File
@@ -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");
});
});
});
+245
View File
@@ -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"
);
});
});
});
+160
View File
@@ -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");
});
});
});
+19
View File
@@ -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"],
},
},
});
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
// Global test setup
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
});