diff --git a/.gitignore b/.gitignore index 6ad576f..d35a5e3 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ yarn-error.log* next-env.d.ts /src/generated/prisma + +/db/data diff --git a/app/components/company/company-form.tsx b/app/components/company/company-form.tsx index 8fd0ade..1080593 100644 --- a/app/components/company/company-form.tsx +++ b/app/components/company/company-form.tsx @@ -21,6 +21,7 @@ const schema = z.object({ bankBic: z.string().optional(), bankName: z.string().optional(), invoicePrefix: z.string().optional(), + kleinunternehmer: z.boolean().optional(), }); type FormData = z.infer; @@ -44,7 +45,7 @@ function Field({ label, error, children }: { label: string; error?: string; chil export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) { const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({ resolver: zodResolver(schema), - defaultValues: { country: "DE", invoicePrefix: "RE", ...defaultValues }, + defaultValues: { country: "DE", invoicePrefix: "RE", kleinunternehmer: false, ...defaultValues }, }); return ( @@ -124,6 +125,18 @@ export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern"

Format: {"{Präfix}"}-{"{Jahr}"}-{"{Nummer}"} z.B. RE-2024-001

+
+ +
diff --git a/app/components/invoice/invoice-form.tsx b/app/components/invoice/invoice-form.tsx index 72c3555..457382b 100644 --- a/app/components/invoice/invoice-form.tsx +++ b/app/components/invoice/invoice-form.tsx @@ -5,7 +5,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { calcItemAmounts, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax"; +import { calcItemAmounts, calcItemAmountsKleinunternehmer, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax"; import { Plus, Trash2 } from "lucide-react"; interface Customer { @@ -39,6 +39,7 @@ interface InvoiceFormProps { companyId: string; onSubmit: (data: Record) => Promise; defaultValues?: Partial; + defaultKleinunternehmer?: boolean; } const defaultItem = (): ItemFormData => ({ @@ -53,9 +54,10 @@ const defaultItem = (): ItemFormData => ({ grossAmount: 0, }); -export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps) { +export function InvoiceForm({ customers, companyId, onSubmit, defaultKleinunternehmer = false }: InvoiceFormProps) { const today = new Date().toISOString().split("T")[0]; const dueDefault = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; + const [kleinunternehmer, setKleinunternehmer] = useState(defaultKleinunternehmer); const { register, handleSubmit, watch, setValue, control, formState: { errors, isSubmitting } } = useForm({ defaultValues: { @@ -76,12 +78,19 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps if (!item) return; const qty = parseFloat(item.quantity) || 0; const price = parseFloat(item.unitPrice) || 0; - const rate = parseFloat(item.taxRate) || 0; - const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate); - setValue(`items.${index}.netAmount`, netAmount); - setValue(`items.${index}.taxAmount`, taxAmount); - setValue(`items.${index}.grossAmount`, grossAmount); - }, [watchedItems, setValue]); + if (kleinunternehmer) { + const { netAmount, taxAmount, grossAmount } = calcItemAmountsKleinunternehmer(qty, price); + setValue(`items.${index}.netAmount`, netAmount); + setValue(`items.${index}.taxAmount`, taxAmount); + setValue(`items.${index}.grossAmount`, grossAmount); + } else { + const rate = parseFloat(item.taxRate) || 0; + const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate); + setValue(`items.${index}.netAmount`, netAmount); + setValue(`items.${index}.taxAmount`, taxAmount); + setValue(`items.${index}.grossAmount`, grossAmount); + } + }, [watchedItems, setValue, kleinunternehmer]); const totals = calcInvoiceTotals( watchedItems.map((item) => ({ @@ -95,6 +104,20 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps const items = data.items.map((item, i) => { const qty = parseFloat(item.quantity) || 0; const price = parseFloat(item.unitPrice) || 0; + if (kleinunternehmer) { + const { netAmount, taxAmount, grossAmount } = calcItemAmountsKleinunternehmer(qty, price); + return { + position: i + 1, + description: item.description, + quantity: qty, + unit: item.unit || undefined, + unitPrice: price, + taxRate: 0, + netAmount, + taxAmount, + grossAmount, + }; + } const rate = parseFloat(item.taxRate) || 0; const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate); return { @@ -119,6 +142,7 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps deliveryDate: data.deliveryDate || undefined, dueDate: data.dueDate, notes: data.notes || undefined, + kleinunternehmer, items, ...totals, }); @@ -141,6 +165,23 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps {errors.customerId &&

Pflichtfeld

}
+ +
+ +
@@ -172,18 +213,18 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
-
+
Beschreibung
Menge
Einh.
-
Einzelpreis
-
MwSt.
+
{kleinunternehmer ? "Einzelpreis (brutto)" : "Einzelpreis"}
+ {!kleinunternehmer &&
MwSt.
}
Gesamt (brutto)
{fields.map((field, index) => ( -
+
recalcItem(index)} />
-
- -
+ {!kleinunternehmer && ( +
+ +
+ )}
{formatCurrency(watchedItems[index]?.grossAmount ?? 0)}
@@ -254,18 +297,32 @@ export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps
-
- Netto - {formatCurrency(totals.netTotal)} -
-
- MwSt. - {formatCurrency(totals.taxTotal)} -
-
- Gesamt (brutto) - {formatCurrency(totals.grossTotal)} -
+ {kleinunternehmer ? ( + <> +
+ Gesamtbetrag + {formatCurrency(totals.grossTotal)} +
+

+ Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt. +

+ + ) : ( + <> +
+ Netto + {formatCurrency(totals.netTotal)} +
+
+ MwSt. + {formatCurrency(totals.taxTotal)} +
+
+ Gesamt (brutto) + {formatCurrency(totals.grossTotal)} +
+ + )}
diff --git a/app/components/invoice/invoice-pdf.tsx b/app/components/invoice/invoice-pdf.tsx index d57cf62..8674aea 100644 --- a/app/components/invoice/invoice-pdf.tsx +++ b/app/components/invoice/invoice-pdf.tsx @@ -204,6 +204,7 @@ interface InvoicePDFProps { deliveryDate?: Date | string | null; dueDate: Date | string; notes?: string | null; + kleinunternehmer?: boolean; netTotal: number | string | { toString(): string }; taxTotal: number | string | { toString(): string }; grossTotal: number | string | { toString(): string }; @@ -223,7 +224,6 @@ interface InvoicePDFProps { }; customer: { name: string; - vatId?: string | null; address: string; zip: string; city: string; @@ -296,11 +296,6 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) { {invoice.customer.country !== "DE" && ( {invoice.customer.country} )} - {invoice.customer.vatId && ( - - USt-IdNr.: {invoice.customer.vatId} - - )} @@ -330,9 +325,13 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) { Beschreibung Menge Einh. - EP (netto) - MwSt. - Gesamt (brutto) + + {invoice.kleinunternehmer ? "EP (brutto)" : "EP (netto)"} + + {!invoice.kleinunternehmer && ( + MwSt. + )} + Gesamt {invoice.items.map((item, idx) => ( @@ -342,7 +341,9 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) { {n(item.quantity)} {item.unit ?? ""} {formatMoney(n(item.unitPrice))} - {n(item.taxRate)}% + {!invoice.kleinunternehmer && ( + {n(item.taxRate)}% + )} {formatMoney(n(item.grossAmount))} @@ -351,20 +352,36 @@ export function InvoicePDFDocument({ invoice }: InvoicePDFProps) { - - Nettobetrag - {formatMoney(n(invoice.netTotal))} - - {Object.entries(taxGroups).map(([rate, { net, tax }]) => ( - - MwSt. {rate}% auf {formatMoney(net)} - {formatMoney(tax)} - - ))} - - Gesamtbetrag (inkl. MwSt.) - {formatMoney(n(invoice.grossTotal))} - + {invoice.kleinunternehmer ? ( + <> + + Gesamtbetrag + {formatMoney(n(invoice.grossTotal))} + + + + Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt. + + + + ) : ( + <> + + Nettobetrag + {formatMoney(n(invoice.netTotal))} + + {Object.entries(taxGroups).map(([rate, { net, tax }]) => ( + + MwSt. {rate}% auf {formatMoney(net)} + {formatMoney(tax)} + + ))} + + Gesamtbetrag (inkl. MwSt.) + {formatMoney(n(invoice.grossTotal))} + + + )} diff --git a/app/components/layout/topbar.tsx b/app/components/layout/topbar.tsx index 5d687b8..f3a6b97 100644 --- a/app/components/layout/topbar.tsx +++ b/app/components/layout/topbar.tsx @@ -1,5 +1,5 @@ -import { useMatches, useLocation, Link } from "react-router"; -import { ChevronRight, LayoutDashboard } from "lucide-react"; +import { useMatches, useLocation, Link, Form } from "react-router"; +import { ChevronRight, LayoutDashboard, LogOut } from "lucide-react"; interface Breadcrumb { label: string; @@ -107,7 +107,7 @@ export function Topbar({ userName }: { userName?: string | null }) { )} - {/* User */} + {/* User + Logout */} {userName && (
{userName} @@ -127,6 +127,23 @@ export function Topbar({ userName }: { userName?: string | null }) { > {getInitials(userName)}
+
+ +
)} diff --git a/app/lib/tax.ts b/app/lib/tax.ts index a385fd0..88884e5 100644 --- a/app/lib/tax.ts +++ b/app/lib/tax.ts @@ -15,6 +15,14 @@ export function calcItemAmounts( return { netAmount, taxAmount, grossAmount }; } +export function calcItemAmountsKleinunternehmer( + quantity: number, + unitPrice: number +) { + const grossAmount = Math.round(quantity * unitPrice * 100) / 100; + return { netAmount: grossAmount, taxAmount: 0, grossAmount }; +} + export function calcInvoiceTotals( items: Array<{ netAmount: number; taxAmount: number; grossAmount: number }> ) { diff --git a/app/routes/api.companies.$id.ts b/app/routes/api.companies.$id.ts index 18d81f9..56ff1b7 100644 --- a/app/routes/api.companies.$id.ts +++ b/app/routes/api.companies.$id.ts @@ -18,6 +18,7 @@ const companySchema = z.object({ bankBic: z.string().optional(), bankName: z.string().optional(), invoicePrefix: z.string().optional(), + kleinunternehmer: z.boolean().optional(), }); export async function loader({ request, params }: { request: Request; params: { id: string } }) { diff --git a/app/routes/api.companies.ts b/app/routes/api.companies.ts index f262af3..79a6764 100644 --- a/app/routes/api.companies.ts +++ b/app/routes/api.companies.ts @@ -17,6 +17,7 @@ const companySchema = z.object({ bankBic: z.string().optional(), bankName: z.string().optional(), invoicePrefix: z.string().optional().default("RE"), + kleinunternehmer: z.boolean().optional().default(false), }); export async function loader({ request }: { request: Request }) { diff --git a/app/routes/api.invoices.ts b/app/routes/api.invoices.ts index 67238a6..a018a78 100644 --- a/app/routes/api.invoices.ts +++ b/app/routes/api.invoices.ts @@ -22,6 +22,7 @@ const invoiceSchema = z.object({ deliveryDate: z.string().optional(), dueDate: z.string(), notes: z.string().optional(), + kleinunternehmer: z.boolean().optional().default(false), items: z.array(itemSchema).min(1), netTotal: z.number(), taxTotal: z.number(), diff --git a/app/routes/companies.$id.invoices.$invoiceId.tsx b/app/routes/companies.$id.invoices.$invoiceId.tsx index c03a09a..c46c0dc 100644 --- a/app/routes/companies.$id.invoices.$invoiceId.tsx +++ b/app/routes/companies.$id.invoices.$invoiceId.tsx @@ -45,6 +45,7 @@ export async function loader({ netTotal: Number(invoice.netTotal), taxTotal: Number(invoice.taxTotal), grossTotal: Number(invoice.grossTotal), + kleinunternehmer: invoice.kleinunternehmer, issueDate: invoice.issueDate.toISOString(), dueDate: invoice.dueDate.toISOString(), deliveryDate: invoice.deliveryDate?.toISOString() ?? null, @@ -184,9 +185,6 @@ export default function InvoiceDetailPage() {

{invoice.customer.name}

{invoice.customer.address}

{invoice.customer.zip} {invoice.customer.city}

- {invoice.customer.vatId && ( -

USt-IdNr.: {invoice.customer.vatId}

- )}
@@ -242,20 +240,34 @@ export default function InvoiceDetailPage() {
-
- Nettobetrag - {formatCurrency(invoice.netTotal)} -
- {Object.entries(taxGroups).map(([rate, { net, tax }]) => ( -
- MwSt. {rate}% auf {formatCurrency(net)} - {formatCurrency(tax)} -
- ))} -
- Gesamtbetrag (brutto) - {formatCurrency(invoice.grossTotal)} -
+ {invoice.kleinunternehmer ? ( + <> +
+ Gesamtbetrag + {formatCurrency(invoice.grossTotal)} +
+

+ Dieser Rechnungsbetrag enthält nach §19 Abs. 1 UStG keine USt. +

+ + ) : ( + <> +
+ Nettobetrag + {formatCurrency(invoice.netTotal)} +
+ {Object.entries(taxGroups).map(([rate, { net, tax }]) => ( +
+ MwSt. {rate}% auf {formatCurrency(net)} + {formatCurrency(tax)} +
+ ))} +
+ Gesamtbetrag (brutto) + {formatCurrency(invoice.grossTotal)} +
+ + )}
@@ -285,18 +297,30 @@ export default function InvoiceDetailPage() { Zusammenfassung -
-

Netto

-

{formatCurrency(invoice.netTotal)}

-
-
-

MwSt.

-

{formatCurrency(invoice.taxTotal)}

-
-
-

Brutto

-

{formatCurrency(invoice.grossTotal)}

-
+ {invoice.kleinunternehmer ? ( + <> +
+

Gesamtbetrag

+

{formatCurrency(invoice.grossTotal)}

+
+

Keine USt. gem. §19 UStG

+ + ) : ( + <> +
+

Netto

+

{formatCurrency(invoice.netTotal)}

+
+
+

MwSt.

+

{formatCurrency(invoice.taxTotal)}

+
+
+

Brutto

+

{formatCurrency(invoice.grossTotal)}

+
+ + )}
diff --git a/app/routes/companies.$id.invoices.new.tsx b/app/routes/companies.$id.invoices.new.tsx index 45bca15..92fac9b 100644 --- a/app/routes/companies.$id.invoices.new.tsx +++ b/app/routes/companies.$id.invoices.new.tsx @@ -74,7 +74,7 @@ export default function NewInvoicePage() { Rechnungsdaten - + diff --git a/db/docker-compose.yml b/db/docker-compose.yml index 2930f4f..7815b84 100644 --- a/db/docker-compose.yml +++ b/db/docker-compose.yml @@ -9,7 +9,7 @@ services: MYSQL_USER: annas_user MYSQL_PASSWORD: annas_password volumes: - - mariadb_data:/var/lib/mysql + - ./data:/var/lib/mysql ports: - "3306:3306" networks: @@ -39,9 +39,6 @@ services: networks: - db_network -volumes: - mariadb_data: - networks: db_network: driver: bridge diff --git a/package.json b/package.json index 9629416..a33dcb2 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,14 @@ "private": true, "type": "module", "scripts": { - "dev": "docker-compose -f db/docker-compose.yml up -d && react-router dev", + "devfull": "docker-compose -f db/docker-compose.yml up -d && react-router dev", + "dev": "react-router dev", "build": "react-router build", "start": "react-router-serve ./build/server/index.js", "typecheck": "react-router typegen && tsc", "lint": "eslint", "db:migrate": "prisma migrate dev", - "db:seed": "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" }, "prisma": { diff --git a/prisma/migrations/20260311214225_add_kleinunternehmer_to_invoice/migration.sql b/prisma/migrations/20260311214225_add_kleinunternehmer_to_invoice/migration.sql new file mode 100644 index 0000000..a7e00bd --- /dev/null +++ b/prisma/migrations/20260311214225_add_kleinunternehmer_to_invoice/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `invoices` ADD COLUMN `kleinunternehmer` BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20260311221959_add_kleinunternehmer_to_company/migration.sql b/prisma/migrations/20260311221959_add_kleinunternehmer_to_company/migration.sql new file mode 100644 index 0000000..3a462d6 --- /dev/null +++ b/prisma/migrations/20260311221959_add_kleinunternehmer_to_company/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `companies` ADD COLUMN `kleinunternehmer` BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fe59189..417f245 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,8 +3,9 @@ generator client { } datasource db { - provider = "mysql" - url = env("DATABASE_URL") + provider = "mysql" + url = env("DATABASE_URL") + shadowDatabaseUrl = env("SHADOW_DATABASE_URL") } model User { @@ -37,6 +38,7 @@ model Company { bankName String? invoicePrefix String @default("RE") invoiceSequence Int @default(0) + kleinunternehmer Boolean @default(false) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) customers Customer[] @@ -76,8 +78,9 @@ model Invoice { issueDate DateTime deliveryDate DateTime? dueDate DateTime - status InvoiceStatus @default(DRAFT) - notes String? @db.Text + status InvoiceStatus @default(DRAFT) + kleinunternehmer Boolean @default(false) + notes String? @db.Text items InvoiceItem[] netTotal Decimal @db.Decimal(10, 2) taxTotal Decimal @db.Decimal(10, 2) diff --git a/prisma/seed.ts b/prisma/seed.ts index 41a3359..5a54cec 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -51,7 +51,6 @@ async function main() { id: "demo-customer-1", companyId: company.id, name: "Beispiel AG", - vatId: "DE987654321", address: "Beispielweg 5", zip: "20095", city: "Hamburg", @@ -61,8 +60,11 @@ async function main() { console.log(`✓ Customer created: ${customer.name}`); // Create demo invoice - const invoice = await prisma.invoice.create({ - data: { + const invoice = await prisma.invoice.upsert({ + where: { id: "demo-invoice-1" }, + update: {}, + create: { + id: "demo-invoice-1", number: "RE-2024-001", companyId: company.id, customerId: customer.id,