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.
This commit is contained in:
@@ -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,153 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Decimal } from "@prisma/client";
|
||||
|
||||
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";
|
||||
|
||||
// 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 = "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 15 default expense categories", () => {
|
||||
expect(DEFAULT_AUSGABE_KATEGORIEN).toHaveLength(15);
|
||||
});
|
||||
|
||||
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 9 default revenue categories", () => {
|
||||
expect(DEFAULT_EINNAHME_KATEGORIEN).toHaveLength(9);
|
||||
});
|
||||
|
||||
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(15);
|
||||
expect(defaultRevenueCategories).toHaveLength(9);
|
||||
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(16); // 15 defaults + 1 custom
|
||||
expect(customCategories[15]).toBe("Custom Expense Category");
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
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,158 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user