diff --git a/docs/rfc/016-employment-income-ocr.md b/docs/rfc/016-employment-income-ocr.md new file mode 100644 index 0000000..848b8eb --- /dev/null +++ b/docs/rfc/016-employment-income-ocr.md @@ -0,0 +1,670 @@ +# RFC-016: §6 Employment Income (DPC/DPP/HPP) with OCR + +**Status:** Proposed +**Date:** 2026-05-02 + +## Summary + +Adds full support for Czech §6 income (závislá činnost — DPČ, DPP, HPP employment) into the DPFO income tax return. Users upload a PDF/image of "Potvrzení o zdanitelných příjmech ze závislé činnosti" issued by their employer; an AI vision model extracts the structured fields, the user confirms, and the data flows into rows 31, 33, 34, 36, 42, 76, 84, 87 of DPFO and into the EPO XML attributes that already exist in the schema. Two Potvrzení variants are supported: zálohové (form 25 5460 vzor 33) and srážkové (form 25 5460/A vzor 12 — optional inclusion in DAP per § 36 odst. 6/7 ZDP). + +## Background + +The `IncomeTaxReturn` domain currently only models §7 (business), §8 (capital), and §10 (other income). The XML generator at `internal/annualtaxxml/income_tax_gen.go:210` explicitly hardcodes `// ř.42 -- assumes §6 employment base = 0`. OSVČ who have side employment (typically DPČ for occasional work) cannot file DPFO from this app — they must transcribe rows 31/33/34/84 manually elsewhere. + +The DPFO schema (`docs/xml-schemas/epo/dpfdp7_epo2.xsd`) already exposes every attribute we need; this RFC fills them in instead of inventing new structure. Empty employer Potvrzení handling is the ergonomic win — most users have one or two papers per year, and OCR removes the manual transcription bottleneck (the same pattern used for §15 deductions in RFC-007 and investment docs in RFC-012). + +### Verified facts (form 25 5405/1 vzor 33, year 2025) + +Source: [Pokyny k vyplnění DPFO 2025, financnisprava.gov.cz](https://formulare.kurzy.cz/formulare/financni-sprava/2025/5405-1_33.pdf), §1–§7 oddíly. + +| Row | Meaning | Source | +|----|---------|--------| +| ř.31 | Úhrn příjmů §6 od všech zaměstnavatelů (vč. zahraničí + příjmů §36/6/7 zařazených do DAP) | součet ř.2 + ř.4 z Potvrzení vzor 33; ř.2 z Potvrzení 25 5460/A vzor 12 (jen pokud uživatel zahrne srážkovou daň do DAP) | +| ř.32 | **Neobsazeno** v aktuálním vzoru 33 (literal "ř. 32 Neobsazeno" v pokynech 2025, str. 2). Superhrubá mzda zrušena od 2021 (zákon 609/2020 Sb.). XSD docstring na `kc_prij6` zmiňuje "do ř. 32", ale je to stale text z předchozích vzorů — pokyny 2025 jednoznačně směrují srážkové příjmy do ř.31. | — | +| ř.33 | Daň zaplacená v zahraničí podle §6 odst. 13 (jen daň. rezident ČR) | doložené potvrzení o dani zaplacené v zahraničí | +| ř.34 | Dílčí ZD §6 = ř.31 − ř.33 | computed | +| ř.35 | Část ř.31 = příjmy, u kterých neměl plátce povinnost srazit zálohy dle §38h (typicky příjmy zaměstnanců zahraničních zastupitelských úřadů v tuzemsku dle §38c, příjmy ze zdrojů v zahraničí) | informativní, neovlivňuje ZD | +| ř.36 | Dílčí ZD §6 (přenos ř.34) | computed | +| ř.42 | Základ daně = ř.36 + max(0, ř.41) — pokud je ř.41 záporné, jen ř.36 | computed | +| ř.76 | Daňový bonus = ř.72 − ř.73 (computed; nárok na bonus po uplatnění daně) | computed — **nikoliv** výplata zaměstnavatelem | +| ř.84 | Úhrn sražených záloh §6 (po slevách na dani; po RZ snížený o vrácený přeplatek) | ř.8 Potvrzení vzor 33 | +| ř.87 | Sražená daň §36 odst. 6 zařazená do DAP (rezident ČR) | Potvrzení 25 5460/A vzor 12; volitelné | +| ř.87a | Sražená daň §36 odst. 7 (nerezident, daň. rezident EU/EHP) | nerelevantní pro tuzemské OSVČ | +| ř.89 | Úhrn vyplacených měsíčních daňových bonusů §35d zaměstnavatelem | ř.5 + ř.13 Potvrzení vzor 33 (per XSD doc na `kc_vyplbonus`: "součet ř.5 a 13"; doplatek z RZ je obsažen v ř.13) | + +### Verified XSD attributes (already present in `dpfdp7_epo2.xsd`) + +| Element | Attribute | Row | Comment | +|---------|-----------|-----|---------| +| `VetaO` | `kc_prij6` | ř.31 | "Vyplňte údaje, které zjistíte … z Potvrzení o zdanitelných příjmech ze závislé činnosti …" | +| `VetaO` | `kc_dan_zah` | ř.33 | jen rezidenti ČR se zahraničním příjmem | +| `VetaO` | `kc_zd6` | ř.34/36 | "Přeneste údaj z ř. 34" | +| `VetaO` | `kc_prij6zahr` | ř.35 | "Část příjmů z ř. 31, u kterých neměl plátce povinnost srazit zálohy" | +| `VetaO` | `kc_zd6p` | §38f / Příloha 3 | "Vypočtená částka tvoří dílčí základ daně připadající na příjmy ze závislé činnosti" — alokace §6 portionu pro zápočet zahraniční daně (Příloha č. 3 DAP). MVP: 0 / omitempty. **Nesouvisí s §16a** (Příloha 4 — vybrané zahr. příjmy) ani s progresivní sazbou §16 odst. 1. | +| `VetaO` | `kc_zakldan23` | ř.42 | XSD doc: "Pokud je ř. 41 záporný, uveďte pouze hodnotu z ř. 36" | +| `VetaD` | `kc_zalzavc` | ř.84 | "ve vzoru Potvrzení č. 33 se jedná o údaj uvedený na řádku 8" | +| `VetaD` | `kc_sraz_6_4` | ř.87 | sražená daň §36 odst. 6 (rezident ČR) | +| `VetaD` | `kc_sraz_rezehp` | ř.87a | nerezident EU/EHP | +| `VetaD` | `kc_vyplbonus` | ř.89 | "úhrn měsíčních daňových bonusů, které Vám zaměstnavatel vyplatil" — z Potvrzení ř.5 + ř.13 (vzor 33) | +| `VetaB` | `potv_zam` | příloha count | počet Potvrzení vzor 33 | +| `VetaB` | `potv_36` | příloha count | počet Potvrzení vzor 12 | +| `VetaB` | `potv_dazvyh` | příloha count | počet Potvrzení o vyplaceném daňovém bonusu | + +### Legislative notes + +- **Superhrubá mzda zrušena od 1. 1. 2021** (zákon 609/2020 Sb., § 6 odst. 12 ZDP). Pojistné odvedené zaměstnavatelem se nepřičítá ke ZD. Pole `kc_uhrn_pov` v původním návrhu této RFC bylo proto chybné. +- **Sleva na poplatníka 30 840 Kč ročně (§ 35ba odst. 1 písm. a)** se v DAP uplatňuje **vždy celá**. Pokud zaměstnavatel uplatňoval měsíčně 1/12, kompenzuje se to už ve výši sražených záloh na ř.84 — žádná korekce v `IncomeTaxReturn.CreditBasic` není potřeba. +- **§36 odst. 6 a 7 ZDP** umožňuje poplatníku zahrnout do DAP příjmy zdaněné srážkovou daní zvláštní sazbou (typicky DPP do limitu, DPČ bez prohlášení k dani). Pak musí podle § 38g odst. 6 ZDP do ř.31 zahrnout **veškeré** takové příjmy, ne jen vybrané — sledováno v UI varování. +- **Daňový bonus na děti** vyplacený zaměstnavatelem (Potvrzení vzor 33 ř.5 + ř.13) **se v DAP nesráží od nárokovaného ročního bonusu** (ř.72 / ř.73 / ř.76 zůstávají vypočtené plnou logikou ChildBenefit). Vyplacená částka se reportuje samostatně na **ř.89** (`kc_vyplbonus`) a finální zúčtování proběhne mezi ř.77/77a a ř.89 ve výpočtu doplatku/přeplatku — žádné odečítání v `IncomeTaxReturn.ChildBenefit` není potřeba ani správné. +- **Progresivní sazba 23 % je v §16 odst. 1 ZDP**, nikoliv v §16a. Limit pro 2025 je 36× průměrná mzda = 1 676 052 Kč, počítá se ze součtu všech dílčích základů (§6 + §7 + §8 + §10). Existující `TaxAt15` / `TaxAt23` výpočet v `IncomeTaxReturn` je v rámci §16 a stačí mu přidat `Section6TaxBase` do vstupní `TaxBase` proměnné. +- **§16a samostatný základ daně** je samostatný institut (Příloha č. 4 DAP, ř.74a a ř.414): zdanění vybraných zahraničních příjmů (např. dividendy ze smluvních států) sazbou 15 %. **Nesouvisí** s §6 závislou činností. XSD atribut `kc_zd6p` ("dílčí ZD §6 přepočtená") je pravděpodobně určen pro alokaci §6 portionu při zápočtu zahraniční daně dle §38f (Příloha č. 3) — pro MVP se nepoužívá, emit jako 0 / omitempty. +- **Lhůta vystavení Potvrzení**: § 38j odst. 3 ZDP — plátce daně vystaví Potvrzení **do 10 dnů od podání žádosti** poplatníkem, nikoliv automaticky do 1. března. Pokud uživatel Potvrzení ještě nemá, ať si ho zaměstnavatele vyžádá písemně. + +## Implementation + +### Database (Migration 027) + +```sql +-- +goose Up + +-- Naskenovaná Potvrzení (PDF/JPG/PNG/WEBP) +CREATE TABLE employment_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + year INTEGER NOT NULL, + document_kind TEXT NOT NULL DEFAULT 'advance', -- advance | withholding | bonus + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + storage_path TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + extraction_status TEXT NOT NULL DEFAULT 'pending', -- pending | extracted | failed + extraction_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX idx_employment_docs_year ON employment_documents(year); + +-- Vyextrahovaný / ručně zadaný certifikát (1 plátce, 1 typ Potvrzení, 1 období) +CREATE TABLE employment_income_certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + year INTEGER NOT NULL, + document_id INTEGER REFERENCES employment_documents(id) ON DELETE SET NULL, + certificate_type TEXT NOT NULL DEFAULT 'advance', -- advance | withholding + employer_name TEXT NOT NULL DEFAULT '', + employer_ico TEXT NOT NULL DEFAULT '', + employer_address TEXT NOT NULL DEFAULT '', + contract_type TEXT NOT NULL DEFAULT 'dpc', -- dpc | dpp | hpp | other + period_from TEXT NOT NULL, + period_to TEXT NOT NULL, + -- Z Potvrzení 25 5460 vzor 33 (advance) + gross_income INTEGER NOT NULL DEFAULT 0, -- ř.2 + ř.4 Potvrzení -> ř.31 DAP + income_without_advance INTEGER NOT NULL DEFAULT 0, -- část bez záloh dle §38h (zahr. zastup. úřady, zahr. zaměstnavatelé) -> ř.35 DAP + foreign_tax_paid INTEGER NOT NULL DEFAULT 0, -- §6 odst.13 daň zaplacená v zahraničí -> ř.33 DAP + advance_tax_withheld INTEGER NOT NULL DEFAULT 0, -- ř.8 Potvrzení -> ř.84 DAP + annual_settlement_refund INTEGER NOT NULL DEFAULT 0, -- vrácený přeplatek z RZ (snižuje ř.84) + monthly_bonus_paid INTEGER NOT NULL DEFAULT 0, -- ř.5 + ř.13 Potvrzení -> ř.89 DAP (kc_vyplbonus) + -- Z Potvrzení 25 5460/A vzor 12 (withholding) + withheld_final_tax INTEGER NOT NULL DEFAULT 0, -- §36/6/7 sražená daň -> ř.87 DAP + include_withholding_in_dap INTEGER NOT NULL DEFAULT 0, -- 1 = zahrnout do ř.31 a ř.87 + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'draft', -- draft | confirmed + deleted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (year, employer_ico, certificate_type, period_from, period_to) + ON CONFLICT REPLACE +); +CREATE INDEX idx_employment_certs_year ON employment_income_certificates(year); + +-- §6 agregáty na income_tax_returns +ALTER TABLE income_tax_returns ADD COLUMN section6_gross_income INTEGER NOT NULL DEFAULT 0; -- ř.31 +ALTER TABLE income_tax_returns ADD COLUMN section6_income_without_advance INTEGER NOT NULL DEFAULT 0; -- ř.35 +ALTER TABLE income_tax_returns ADD COLUMN section6_foreign_tax INTEGER NOT NULL DEFAULT 0; -- ř.33 +ALTER TABLE income_tax_returns ADD COLUMN section6_tax_base INTEGER NOT NULL DEFAULT 0; -- ř.34/36 +ALTER TABLE income_tax_returns ADD COLUMN section6_advance_withheld INTEGER NOT NULL DEFAULT 0; -- ř.84 +ALTER TABLE income_tax_returns ADD COLUMN section6_withholding_credited INTEGER NOT NULL DEFAULT 0; -- ř.87 +ALTER TABLE income_tax_returns ADD COLUMN section6_monthly_bonus_paid INTEGER NOT NULL DEFAULT 0; -- ř.89 (kc_vyplbonus) +ALTER TABLE income_tax_returns ADD COLUMN section6_certs_advance INTEGER NOT NULL DEFAULT 0; -- potv_zam count +ALTER TABLE income_tax_returns ADD COLUMN section6_certs_withholding INTEGER NOT NULL DEFAULT 0; -- potv_36 count +ALTER TABLE income_tax_returns ADD COLUMN section6_certs_bonus INTEGER NOT NULL DEFAULT 0; -- potv_dazvyh count + +-- +goose Down +ALTER TABLE income_tax_returns DROP COLUMN section6_gross_income; +ALTER TABLE income_tax_returns DROP COLUMN section6_income_without_advance; +ALTER TABLE income_tax_returns DROP COLUMN section6_foreign_tax; +ALTER TABLE income_tax_returns DROP COLUMN section6_tax_base; +ALTER TABLE income_tax_returns DROP COLUMN section6_advance_withheld; +ALTER TABLE income_tax_returns DROP COLUMN section6_withholding_credited; +ALTER TABLE income_tax_returns DROP COLUMN section6_monthly_bonus_paid; +ALTER TABLE income_tax_returns DROP COLUMN section6_certs_advance; +ALTER TABLE income_tax_returns DROP COLUMN section6_certs_withholding; +ALTER TABLE income_tax_returns DROP COLUMN section6_certs_bonus; +DROP TABLE IF EXISTS employment_income_certificates; +DROP TABLE IF EXISTS employment_documents; +``` + +### Domain Types + +`internal/domain/employment_income.go`: + +```go +package domain + +import "time" + +type EmploymentDocumentKind string + +const ( + EmploymentDocAdvance EmploymentDocumentKind = "advance" + EmploymentDocWithholding EmploymentDocumentKind = "withholding" + EmploymentDocBonus EmploymentDocumentKind = "bonus" +) + +type CertificateType string + +const ( + CertificateAdvance CertificateType = "advance" + CertificateWithholding CertificateType = "withholding" +) + +type ContractType string + +const ( + ContractDPC ContractType = "dpc" + ContractDPP ContractType = "dpp" + ContractHPP ContractType = "hpp" + ContractOther ContractType = "other" +) + +type EmploymentDocument struct { + ID int64 + Year int + Kind EmploymentDocumentKind + Filename string + ContentType string + StoragePath string + Size int64 + ExtractionStatus string + ExtractionError string + CreatedAt time.Time + UpdatedAt time.Time +} + +type EmploymentCertificate struct { + ID int64 + Year int + DocumentID *int64 + CertificateType CertificateType + EmployerName string + EmployerICO string + EmployerAddress string + ContractType ContractType + PeriodFrom time.Time + PeriodTo time.Time + GrossIncome Amount // ř.2 + ř.4 Potvrzení -> ř.31 DAP + IncomeWithoutAdvance Amount // bez záloh dle §38h -> ř.35 DAP + ForeignTaxPaid Amount // §6 odst.13 -> ř.33 DAP + AdvanceTaxWithheld Amount // ř.8 Potvrzení -> ř.84 DAP + AnnualSettlementRefund Amount // vrácený přeplatek z RZ + MonthlyBonusPaid Amount // ř.5 + ř.13 Potvrzení -> ř.89 DAP + WithheldFinalTax Amount + IncludeWithholdingInDAP bool + Notes string + Status string + DeletedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +Extend `IncomeTaxReturn` in `internal/domain/annual_tax.go`: + +```go +// §6 employment income aggregates (DPC/DPP/HPP) +Section6GrossIncome Amount // ř.31 +Section6IncomeWithoutAdvance Amount // ř.35 (informativní; §38h) +Section6ForeignTax Amount // ř.33 +Section6TaxBase Amount // ř.34/36 = ř.31 - ř.33 +Section6AdvanceWithheld Amount // ř.84 (po vrácení přeplatku z RZ) +Section6WithholdingCredited Amount // ř.87 (jen pokud uživatel zahrnul §36/6 do DAP) +Section6MonthlyBonusPaid Amount // ř.89 kc_vyplbonus (vyplacené zaměstnavatelem; NESLEVÍ z ChildBenefit) +Section6CertsAdvance int // count -> potv_zam +Section6CertsWithholding int // count -> potv_36 +Section6CertsBonus int // count -> potv_dazvyh +``` + +### Repository + +| File | Purpose | +|------|---------| +| `internal/repository/employment_document_repo.go` | CRUD analogicky `investment_document_repo.go`, scan helper `scanEmploymentDocument` | +| `internal/repository/employment_certificate_repo.go` | CRUD + `ListByYear`, soft delete via `deleted_at`, scan helper `scanEmploymentCertificate` | + +Add interfaces to `internal/repository/interfaces.go` (lead merge): + +```go +type EmploymentDocumentRepo interface { + Create(ctx context.Context, doc *EmploymentDocument) error + GetByID(ctx context.Context, id int64) (*EmploymentDocument, error) + ListByYear(ctx context.Context, year int) ([]*EmploymentDocument, error) + Delete(ctx context.Context, id int64) error + UpdateExtraction(ctx context.Context, id int64, status, errMsg string) error +} + +type EmploymentCertificateRepo interface { + Create(ctx context.Context, cert *EmploymentCertificate) error + GetByID(ctx context.Context, id int64) (*EmploymentCertificate, error) + Update(ctx context.Context, cert *EmploymentCertificate) error + Delete(ctx context.Context, id int64) error + ListByYear(ctx context.Context, year int) ([]*EmploymentCertificate, error) + ListConfirmedByYear(ctx context.Context, year int) ([]*EmploymentCertificate, error) +} +``` + +### OCR + +`internal/service/ocr/employment_prompt.go` — Czech system prompt instructing the model to: + +1. Identify form variant by header text: + - "25 5460 MFin 5460 - vzor č. 33" or "Potvrzení o zdanitelných příjmech ze závislé činnosti" → `certificate_type: advance` + - "25 5460/A MFin 5460/A - vzor č. 12" or "Potvrzení o vyplacených příjmech … srážkou" → `certificate_type: withholding` +2. Extract employer block (název, IČO, adresa) and zdaňovací období. +3. Detect contract type from textual hints: + - "Dohoda o pracovní činnosti" / "DPČ" → `dpc` + - "Dohoda o provedení práce" / "DPP" → `dpp` + - "Pracovní poměr" / "HPP" → `hpp` +4. Extract amounts row-by-row per Potvrzení layout: + - Vzor 33: ř.2 (úhrn zúčtovaných příjmů), ř.4 (další zdanitelné příjmy), ř.5 (úhrn měsíčních bonusů — část 1), ř.8 (sražené zálohy po slevách), ř.13 (úhrn měsíčních bonusů — část 2 / případný doplatek), případná položka "vrácený přeplatek z ročního zúčtování" + - `monthly_bonus_paid_czk` = ř.5 + ř.13 (per oficiální pokyny 2025 str. 4 a XSD doc na `kc_vyplbonus`) + - Vzor 12: ř.2 (úhrn vyplacených příjmů), položka "sražená daň zvláštní sazbou" (typicky ř.4 nebo 5) +5. Output `confidence` per [0.0–1.0] and `raw_text` (max 2000 znaků) for audit. + +JSON response (`EmploymentExtractionResponse`): + +```json +{ + "certificate_type": "advance|withholding", + "employer_name": "...", + "employer_ico": "...", + "employer_address": "...", + "contract_type": "dpc|dpp|hpp|other", + "period_from": "YYYY-MM-DD", + "period_to": "YYYY-MM-DD", + "gross_income_czk": 0.0, + "income_without_advance_czk": 0.0, + "foreign_tax_paid_czk": 0.0, + "advance_tax_withheld_czk": 0.0, + "annual_settlement_refund_czk": 0.0, + "monthly_bonus_paid_czk": 0.0, + "withheld_final_tax_czk": 0.0, + "confidence": 0.0, + "raw_text": "..." +} +``` + +Tests: `employment_prompt_test.go` covering both variants, missing fields → 0, malformed JSON, OCR confidence threshold. + +### Service + +`internal/service/employment_certificate_svc.go`: + +```go +type EmploymentCertificateService struct { + docs repository.EmploymentDocumentRepo + certs repository.EmploymentCertificateRepo + ocr ocr.Provider + audit AuditLogger + dataDir string +} + +func (s *EmploymentCertificateService) UploadDocument(ctx, year, kind, filename, contentType, content) (*EmploymentDocument, error) +func (s *EmploymentCertificateService) ExtractDocument(ctx, docID) (*EmploymentCertificate, error) +func (s *EmploymentCertificateService) Create(ctx, cert) error +func (s *EmploymentCertificateService) Update(ctx, cert) error +func (s *EmploymentCertificateService) Confirm(ctx, certID) error +func (s *EmploymentCertificateService) ListByYear(ctx, year) ([]*EmploymentCertificate, error) +func (s *EmploymentCertificateService) Delete(ctx, certID) error +``` + +Validation rules: +- `period_from <= period_to`, both within `year` +- `gross_income >= 0`, all amounts non-negative +- `withheld_final_tax > 0` only if `certificate_type = withholding` +- `include_withholding_in_dap = true` only if `certificate_type = withholding` +- `annual_settlement_refund <= advance_tax_withheld` (cannot refund more than withheld) +- IČO format: 8 digits, validated via existing `domain.ValidateICO` + +Storage: `DataDir/employment_docs/{year}/{uuid}_{filename}` with content-type whitelist `application/pdf`, `image/jpeg`, `image/png`, `image/webp` and 10 MB max size. + +Audit categories: `employment_document`, `employment_certificate` — added to `audit_log_handler.go:55`. + +### Tax Calculation + +Extend `internal/service/income_tax_return_svc.go` `Recalculate`: + +```go +// New: load §6 certificates and aggregate +certs, err := s.employmentCerts.ListConfirmedByYear(ctx, year) +if err != nil { return fmt.Errorf("listing employment certificates: %w", err) } +itr.Section6CertsAdvance = 0 +itr.Section6CertsWithholding = 0 +itr.Section6CertsBonus = 0 +itr.Section6GrossIncome = 0 +itr.Section6IncomeWithoutAdvance = 0 +itr.Section6ForeignTax = 0 +itr.Section6AdvanceWithheld = 0 +itr.Section6WithholdingCredited = 0 +itr.Section6MonthlyBonusPaid = 0 +for _, c := range certs { + switch c.CertificateType { + case domain.CertificateAdvance: + itr.Section6GrossIncome += c.GrossIncome + itr.Section6IncomeWithoutAdvance += c.IncomeWithoutAdvance + itr.Section6ForeignTax += c.ForeignTaxPaid + itr.Section6AdvanceWithheld += c.AdvanceTaxWithheld - c.AnnualSettlementRefund + itr.Section6MonthlyBonusPaid += c.MonthlyBonusPaid + itr.Section6CertsAdvance++ + // Section6CertsBonus (potv_dazvyh) NEZVYŠOVAT zde — ten je počet samostatných + // formulářů "Potvrzení o vyplaceném daňovém bonusu" (EmploymentDocBonus kind), + // ne počet advance certifikátů s vyplaceným bonusem. V MVP zůstává 0 + // (viz Out of Scope: "Potvrzení o vyplaceném daňovém bonusu" upload UI). + case domain.CertificateWithholding: + if c.IncludeWithholdingInDAP { + itr.Section6GrossIncome += c.GrossIncome + itr.Section6WithholdingCredited += c.WithheldFinalTax + itr.Section6CertsWithholding++ + } + } +} +itr.Section6TaxBase = itr.Section6GrossIncome - itr.Section6ForeignTax // ř.34 + +// Tax base for §16 progressive 15/23 % calculation +// XSD critical: pokud je úhrn §7+§8+§9+§10 záporný, ZD = jen Section6TaxBase +totalBase := itr.Section6TaxBase +positiveSum := zd7 + zd8 + zd10 // §7+§8+§10 (§9 zatím netracked) +if positiveSum > 0 { totalBase += positiveSum } +// Use totalBase as TaxBase input to existing splitProgressiveTax(taxBase, threshold) + +// MonthlyBonusPaid je INFORMACE pro ř.89, NIKOLIV korekce ChildBenefit. +// ChildBenefit (= ř.72 nárok) zůstává plně vypočtený existující logikou. +// Konečné zúčtování přeplatek/doplatek řeší rozdíl ř.84 + ř.87 + ř.89 vs vypočtená daň/bonus. + +// §16 progressive rate guard (out of scope for MVP) +// 23 % sazba podle § 16 odst. 1 ZDP nad 36× průměrná mzda; pro 2025 limit 1 676 052 Kč +limit := taxConstants.AvgWageMultiplier36x // 1 676 052 CZK pro 2025 +if itr.Section6GrossIncome+positiveSum > limit { + // Existing splitProgressiveTax handles 15/23 split correctly with totalBase. + // The warning fires if user has §6 income + needs to verify zd6p / Příloha 4 §16a apply. + itr.Warnings = append(itr.Warnings, domain.WarningProgressiveRateReview) +} +``` + +`CreditBasic` stays at full annual amount (30 840 Kč 2025) regardless of monthly application by employer — the difference flows back through ř.84 vs the recalculated total tax. + +### XML Generator + +`internal/annualtaxxml/income_tax_types.go` — extend `DPFOVetaO`, `DPFOVetaD`, `DPFOVetaB`: + +```go +type DPFOVetaO struct { + KcPrij6 int64 `xml:"kc_prij6,attr,omitempty"` // ř.31 + KcPrij6zahr int64 `xml:"kc_prij6zahr,attr,omitempty"` // ř.35 + KcDanZah int64 `xml:"kc_dan_zah,attr,omitempty"` // ř.33 + KcZd6 int64 `xml:"kc_zd6,attr,omitempty"` // ř.34/36 + KcZd6p int64 `xml:"kc_zd6p,attr,omitempty"` // alokace §6 portionu pro §38f zápočet zahr. daně (MVP: 0/omit) + KcZd7 int64 `xml:"kc_zd7,attr"` + KcZakldan8 int64 `xml:"kc_zakldan8,attr,omitempty"` + KcZd9 int64 `xml:"kc_zd9,attr,omitempty"` + KcZd10 int64 `xml:"kc_zd10,attr,omitempty"` + KcUhrn int64 `xml:"kc_uhrn,attr"` // ř.41 = ř.37+38+39+40 + KcZakldan23 int64 `xml:"kc_zakldan23,attr"` // ř.42 = ř.36 + max(0, ř.41) + KcZakldan int64 `xml:"kc_zakldan,attr"` // ř.45 +} + +type DPFOVetaD struct { + // existing fields... + KcZalzavc int64 `xml:"kc_zalzavc,attr,omitempty"` // ř.84 + KcSraz64 int64 `xml:"kc_sraz_6_4,attr,omitempty"` // ř.87 + KcSrazRezEHP int64 `xml:"kc_sraz_rezehp,attr,omitempty"` // ř.87a (MVP: 0) + KcVyplBonus int64 `xml:"kc_vyplbonus,attr,omitempty"` // ř.89 úhrn vyplacených měsíčních daňových bonusů +} + +type DPFOVetaB struct { + Priloha1 string `xml:"priloha1,attr,omitempty"` + Priloha2 string `xml:"priloha2,attr,omitempty"` + PotvZam int `xml:"potv_zam,attr,omitempty"` + Potv36 int `xml:"potv_36,attr,omitempty"` + PotvDazvyh int `xml:"potv_dazvyh,attr,omitempty"` +} +``` + +`income_tax_gen.go` — replace lines 209-211: + +```go +// §6 inputs +prij6 := ToWholeCZK(itr.Section6GrossIncome) +prij6zahr := ToWholeCZK(itr.Section6IncomeWithoutAdvance) +danZah := ToWholeCZK(itr.Section6ForeignTax) +zd6 := ToWholeCZK(itr.Section6TaxBase) // = prij6 - danZah + +// Existing §7-§10 +zd7 := ... +zd8 := ToWholeCZK(itr.CapitalIncomeNet) +zd10 := ToWholeCZK(itr.OtherIncomeNet) +uhrn := zd7 + zd8 + zd10 // ř.41 + +// XSD critical: "Pokud je ř.41 záporný, uveďte pouze hodnotu z ř.36" +zakldan23 := zd6 +if uhrn > 0 { + zakldan23 += uhrn +} +``` + +Set: +- `VetaO.KcPrij6 / KcPrij6zahr / KcDanZah / KcZd6` (ř.31, ř.35, ř.33, ř.34/36) +- `VetaD.KcZalzavc / KcSraz64 / KcVyplBonus` (ř.84, ř.87, ř.89) +- `VetaB.PotvZam / Potv36 / PotvDazvyh` (přílohy count) +- `VetaO.KcZd6p` zůstává 0 (omitempty) v MVP — alokace §6 portionu pro §38f zápočet zahraniční daně přidat až s podporou Přílohy 3. + +### HTTP Handler + +`internal/handler/employment_handler.go` — new endpoints: + +| Method | Path | Purpose | +|--------|------|---------| +| POST | `/api/v1/tax/employment/documents?year=&kind=` | multipart upload (max 10 MB; MIME allowlist) | +| POST | `/api/v1/tax/employment/documents/{id}/extract` | run OCR, return draft certificate | +| GET | `/api/v1/tax/employment/documents?year=` | list | +| DELETE | `/api/v1/tax/employment/documents/{id}` | delete file + DB row (cascade SET NULL on certs) | +| GET | `/api/v1/tax/employment/certificates?year=` | list | +| GET | `/api/v1/tax/employment/certificates/{id}` | detail | +| POST | `/api/v1/tax/employment/certificates` | create (manual) | +| PUT | `/api/v1/tax/employment/certificates/{id}` | update draft | +| POST | `/api/v1/tax/employment/certificates/{id}/confirm` | confirm + trigger ITR recompute if exists | +| DELETE | `/api/v1/tax/employment/certificates/{id}` | soft delete | + +DTOs in `helpers.go` (lead merges). Mount in `router.go` with `r.Route("/tax/employment", ...)`. Wire `EmploymentDocumentRepo`, `EmploymentCertificateRepo`, `EmploymentCertificateService` in `serve.go`. + +### Frontend + +#### New page `frontend/src/routes/tax/employment/+page.svelte` + +- Year selector (mirroring `tax/+page.svelte`) +- Two upload tiles: "Nahrát zálohové Potvrzení (vzor 33)", "Nahrát srážkové Potvrzení (vzor 12)" — file input → POST upload → POST extract → open editor with extracted draft + confidence badge +- "Zadat ručně" button — empty editor +- Table of certificates with employer, period, type, gross, ř.84, ř.87, status, action menu +- After confirm/edit: auto-call `incomeTaxApi.recompute(returnId)` if return exists for year + +#### Editor (modal or full-page) + +Sections: +1. Identifikace plátce: name (required), IČO (8 digits via `validateICO`), address +2. Období: `period_from`, `period_to` (within `year`), contract type (DPČ/DPP/HPP/jiné), `notes` +3. Pro `certificate_type=advance` (vzor 33): + - Úhrn zúčtovaných příjmů (ř.2 + ř.4 Potvrzení) → `gross_income` → ř.31 DAP + - Z toho příjmy bez záloh dle §38h (ř.35 DAP — zahr. zastup. úřady, zahr. zaměstnavatelé) → `income_without_advance` + - Daň zaplacená v zahraničí (§6 odst.13) → `foreign_tax_paid` → ř.33 DAP + - Sražené zálohy po slevách (ř.8 Potvrzení) → `advance_tax_withheld` → ř.84 DAP + - Vrácený přeplatek z ročního zúčtování (snižuje sražené zálohy) → `annual_settlement_refund` + - Úhrn vyplacených měsíčních daňových bonusů (ř.5 + ř.13 Potvrzení) → `monthly_bonus_paid` → ř.89 DAP +4. Pro `certificate_type=withholding` (vzor 12): + - Úhrn vyplacených příjmů (ř.2 Potvrzení) → `gross_income` + - Sražená daň zvláštní sazbou → `withheld_final_tax` + - Checkbox "Zahrnout do daňového přiznání (§36 odst.7 ZDP)" → `include_withholding_in_dap` + warning "Pokud zaškrtnete, musíte zahrnout veškeré srážkově zdaněné příjmy z daného typu (§38g odst.6)" + +#### Card on `tax/+page.svelte` + +Add 4th card (after DPFO/CSSZ/ZP) "Závislá činnost (§6)": +- count of certificates (advance + withholding combined) +- ř.31 `Section6GrossIncome`, ř.84 `Section6AdvanceWithheld`, ř.87 `Section6WithholdingCredited` +- "Spravovat" → `/tax/employment` + +#### Section on `tax/income/[id]/+page.svelte` + +Read-only "§6 závislá činnost" panel above existing §7 panel: +- ř.31, ř.33, ř.34/36, ř.84, ř.87, ř.89 with HelpTip on each +- "Upravit certifikáty" link to `/tax/employment?year={year}` + +#### API Client + +Extend `frontend/src/lib/api/client.ts` (lead merges): + +```typescript +export interface EmploymentDocument { ... } +export interface EmploymentCertificate { ... } +export interface EmploymentExtractionResult { /* OCR response */ } +export const employmentApi = { + uploadDocument(year, kind, file): Promise, + extractDocument(id): Promise, + listDocuments(year): Promise, + deleteDocument(id): Promise, + listCertificates(year): Promise, + getCertificate(id): Promise, + createCertificate(cert): Promise, + updateCertificate(id, cert): Promise, + confirmCertificate(id): Promise, + deleteCertificate(id): Promise, +}; +``` + +### Help Content + +Extend `HelpTopicId` union and topics in `frontend/src/lib/data/help-content.ts`: + +| ID | Title | Where used | +|----|-------|-----------| +| `zavisla-cinnost-s6` | Závislá činnost (§6) | header on `/tax/employment`, card on `/tax`, section on income return detail | +| `dpc-dpp-hpp` | Typy pracovních smluv | contract type selector in editor | +| `potvrzeni-zalohove` | Potvrzení o zdanitelných příjmech (vzor 33) | upload tile, gross/withheld fields | +| `potvrzeni-srazkove` | Potvrzení o vyplacených příjmech a sražené dani (vzor 12) | upload tile, withholding fields | +| `srazkova-do-dap` | Zahrnutí srážkové daně do přiznání | `include_withholding_in_dap` checkbox | +| `radek-31-prijmy-s6` | ř.31 Úhrn příjmů §6 | display on income return detail | +| `radek-33-zahranicni-dan` | ř.33 Daň zaplacená v zahraničí | foreign tax field | +| `radek-34-dilci-zaklad-s6` | ř.34/36 Dílčí základ daně §6 | computed display | +| `radek-84-srazene-zalohy` | ř.84 Sražené zálohy zaměstnavateli | display on income return detail | +| `radek-87-srazena-dan` | ř.87 Sražená daň §36 odst.6 | display when withholding used | +| `radek-89-vyplacene-bonusy` | ř.89 Úhrn vyplacených měsíčních daňových bonusů | display when bonus paid (kc_vyplbonus) | +| `rocni-zuctovani-rz` | Roční zúčtování (přeplatek/nedoplatek) | annual_settlement_refund field | +| `superhruba-mzda-zrusena` | Proč není pole na povinné pojistné | shown if user asks "kde je ř.32" | +| `progresivni-sazba-23` | §16 progresivní sazba 23 % nad 36× průměrná mzda | warning when limit exceeded | +| `samostatny-zaklad-16a` | §16a samostatný základ daně (Příloha 4 — vybrané zahr. příjmy) | rozlišovací nápověda — nesouvisí s §6 | + +Each topic has `simple` (for OSVČ without tax background) and `legal` (citing § ZDP and pokyny). Sample structure: + +```typescript +'zavisla-cinnost-s6': { + title: 'Závislá činnost (§6)', + simple: + 'Zde nahrajte Potvrzení o zdanitelných příjmech, které vám vystavil zaměstnavatel za DPČ, DPP nebo hlavní pracovní poměr. Aplikace z něj vyextrahuje údaje a propíše je do řádků 31, 33, 34, 84 a 87 vašeho daňového přiznání.\n\nKaždé Potvrzení od jiného zaměstnavatele uložte zvlášť. Aplikace pozná dvě varianty -- "zálohové" (formulář 25 5460 vzor 33) a "srážkové" (25 5460/A vzor 12). U srážkového se rozhodnete, jestli ho chcete zahrnout do přiznání nebo ne.', + legal: + 'Příjmy ze závislé činnosti definuje § 6 zákona č. 586/1992 Sb. o daních z příjmů. Plátce daně je povinen vystavit Potvrzení do 10 dnů od podání žádosti poplatníkem podle § 38j odst. 3 ZDP.\n\nDílčí základ daně podle § 6 = úhrn příjmů snížený o daň zaplacenou v zahraničí (§ 6 odst. 13). Od 1. 1. 2021 se nepřičítá pojistné odvedené zaměstnavatelem (zrušení superhrubé mzdy zákonem 609/2020 Sb.). Progresivní sazba 23 % je v § 16 odst. 1 ZDP, NIKOLIV v § 16a (ten je samostatný institut samostatného základu daně z vybraných zahraničních příjmů — Příloha č. 4 DAP).' +}, +'potvrzeni-zalohove': { + title: 'Potvrzení o zdanitelných příjmech (vzor 33)', + simple: + 'Tento formulář dostáváte od zaměstnavatele, pokud vám sráží zálohy na daň (typicky DPČ s podepsaným prohlášením, HPP). Najdete na něm úhrn vašich příjmů (ř. 2 + 4), úhrn měsíčních daňových bonusů (ř. 5 + 13) a sražené zálohy po slevách (ř. 8).\n\nPokud Potvrzení nemáte, máte právo o něj zaměstnavatele písemně požádat — vystavit vám ho musí do 10 dnů od žádosti (§ 38j odst. 3 ZDP).\n\nDo aplikace stačí nahrát PDF nebo fotku — AI to přečte za vás. Vždycky si ale zkontrolujte vyextrahované hodnoty, OCR může udělat chybu.', + legal: + 'Formulář MFin 25 5460 vzor č. 33 vydává Ministerstvo financí ČR pro zdaňovací období 2025. Plátce daně je povinen vystavit Potvrzení do 10 dnů od podání žádosti poplatníkem podle § 38j odst. 3 ZDP. Údaje z něj se přenášejí do oddílu 1 Přiznání k DPFO (řádky 31, 33, 34, 36) a oddílu 7 (řádky 84 a 89). Roční zúčtování provádí zaměstnavatel podle § 38ch — pokud bylo provedeno, sražené zálohy na ř. 84 se snižují o vrácený přeplatek.' +}, +// ...etc. +``` + +### Audit Log + +Extend `internal/handler/audit_log_handler.go:55`: + +```go +"document": true, "tax_deduction_document": true, "investment_document": true, +"employment_document": true, "employment_certificate": true, +``` + +### Tests + +| Test | What it covers | +|------|----------------| +| `repository/employment_document_repo_test.go` | CRUD, ListByYear, UpdateExtraction | +| `repository/employment_certificate_repo_test.go` | CRUD, soft delete, ListByYear, ListConfirmedByYear, UNIQUE clause REPLACE | +| `service/ocr/employment_prompt_test.go` | parse JSON for advance + withholding, malformed input, missing fields → 0, code-fence stripping | +| `service/employment_certificate_svc_test.go` | upload + MIME guard, extract via mock OCR provider, validation rules, audit emits, RZ refund subtraction | +| `service/income_tax_return_svc_test.go` (extension) | recompute aggregates §6, ChildBenefit zůstává nezměněn i když je MonthlyBonusPaid > 0 (regression for K3), §16 progressive rate warning when totalBase > 36× průměrná mzda, potv_dazvyh zůstává 0 v MVP i když advance cert má bonus > 0 (regression for N5) | +| `annualtaxxml/income_tax_gen_test.go` (extensions) | `TestIncomeTaxXML_Section6Advance` (2 advance certs → kc_prij6/kc_zd6/kc_zalzavc/kc_vyplbonus/potv_zam=2, potv_dazvyh=0 v MVP), `TestIncomeTaxXML_Section6Withholding` (1 withholding included → kc_sraz_6_4/potv_36=1), `TestIncomeTaxXML_Section6OnlyNegativeSection7` (§6 + §7 ztráta → kc_zakldan23 = kc_zd6, no positive uhrn applied), `TestIncomeTaxXML_BonusReportedSeparately` (cert with monthly_bonus_paid=10000 + ChildBenefit=20000 → ChildBenefit zůstává 20000, kc_vyplbonus=10000, ř.72/76 nezměněny) | +| `handler/employment_handler_test.go` | multipart upload happy path, MIME rejection, oversize rejection, extract endpoint, confirm triggers recompute | +| `routes/tax/employment/page.test.ts` (Vitest) | upload flow with mocked employmentApi, OCR confidence rendering, advance vs withholding form switching, IČO validation, period range guard | +| `routes/tax/income/[id]/page.test.ts` (extension) | §6 panel renders only when `Section6GrossIncome > 0` | +| `tests/integration/employment_flow_test.go` | upload PDF testdata → mock OCR returns vzor 33 JSON → confirm 2 certs → generate DPFO XML → assert `kc_prij6=240000`, `kc_zd6=240000`, `kc_zalzavc=36000`, `kc_vyplbonus=15300`, `potv_zam=2`, `potv_dazvyh=0` (MVP — separate bonus form upload OOS) | + +## Migration Plan + +1. Migration 027 ships dormant — empty tables, agregát columns default 0. Existing DPFO returns regenerate XML identically (new attrs use `omitempty`). +2. UI tile gates feature behind OCR provider config — if no `[ocr]` section in `config.toml`, show "Zadat ručně" button only and link to docs. +3. No data migration. Users with existing DPFO drafts re-open them and § 6 fields are 0 — they upload Potvrzení and re-confirm. + +## Out of Scope + +- **§16a samostatný základ daně** (Příloha č. 4 DAP, ř.74a, ř.414) — zdanění vybraných zahraničních příjmů (např. dividendy ze smluvních států) zvláštní 15 % sazbou. Nesouvisí s §6 ani s progresivní sazbou. UI emit warning at over-limit but emits XML without §16a split. +- **§16 progresivní 23 %** nad 36× průměrná mzda (limit 1 676 052 Kč pro 2025) — existující `splitProgressiveTax` v `income_tax_return_svc.go` dostane do vstupního `TaxBase` Section6TaxBase + ostatní; výpočet 15/23 % funguje korektně bez další úpravy. KcZd6p alokace pro §38f zápočet zahraniční daně mimo MVP. +- **ř.87a** (nerezident EU/EHP) — většina uživatelů jsou tuzemští rezidenti; struct field existuje, UI nezobrazuje. +- **Samostatný formulář "Potvrzení o vyplaceném daňovém bonusu"** (kind=`bonus` v `EmploymentDocument`) — schema umožňuje, ale upload UI ani extraction prompt v MVP nejsou. Důsledek: `Section6CertsBonus` (= XML attribut `potv_dazvyh`) zůstává 0; pokud uživatel toto Potvrzení dostal samostatně (typicky pokud zaměstnavatel nestihl/nemohl vystavit běžné Potvrzení vzor 33), musí se zatím postarat ručně v EPO portálu. +- **Automatické párování dětí na VetaA řádky** — uživatel řádky dětí upravuje ručně mimo §6 modul. +- **§38g odst. 6 enforcement** — UI ukáže warning u `include_withholding_in_dap`, neblokuje částečné zahrnutí. +- **Attachment scanned PDF do EPO XML** — EPO přijímá přílohy přes separate upload step v portálu; generujeme jen DAP XML. Aplikace certifikáty s naskenovanými přílohami nabídne ke stažení jako ZIP pro ruční přiložení. + +## Open Questions + +1. **UNIQUE conflict on (year, employer_ico, type, period_from, period_to):** corrective re-issued Potvrzení currently overwrites via `ON CONFLICT REPLACE`. Alternative: append numeric suffix to `notes` and keep both. Decision: REPLACE (simpler, matches typical user intent of "got new corrected version"). +2. **DPP threshold tracking:** for 2025 the DPP without prohlášení threshold is 11 500 Kč/month (rozhodný příjem pro účast na nemocenském). Should the editor warn when user marks `dpp` and gross/months > limit suggests withholding tax, hinting "ověřte typ Potvrzení"? Decision: yes, advisory warning; do not block. +3. **OCR vendor neutrality:** prompt is Czech-language and tested against `claude` + `openai` providers. Other vendors (`gemini`, `mistral`) have not been validated for this form layout — surface vendor in extraction error if confidence < 0.5. + +## References + +- Pokyny k vyplnění DPFO 2025, vzor č. 33 (financnisprava.gov.cz): https://formulare.kurzy.cz/formulare/financni-sprava/2025/5405-1_33.pdf +- Vyhláška 386/2025 Sb. o formulářových podáních pro daně z příjmů: https://www.zakonyprolidi.cz/cs/2025-386 +- Aktuálně k DPFO 2025, Finanční správa: https://financnisprava.gov.cz/cs/dane/dane/dan-z-prijmu/dotazy-a-odpovedi/dan-z-prijmu-fyzickych-osob/aktualne-k-dani-z-prijmu-fyzickych-osob-2025 +- Zákon č. 586/1992 Sb. o daních z příjmů, § 6, § 16, § 16a, § 35ba, § 35d, § 36, § 38c, § 38f, § 38g, § 38h, § 38ch, § 38j odst. 3 +- Zákon č. 609/2020 Sb. (zrušení superhrubé mzdy od 1. 1. 2021) +- XSD: `docs/xml-schemas/epo/dpfdp7_epo2.xsd` (lokálně) +- Související RFC: 006-annual-tax (DPFO base), 007-tax-credits-deductions (§15 OCR), 012-calc-extraction (calc helpers), 015-pdf-templates + +## Changelog + +### v4 (2026-05-02) — third-round review feedback + +- **Q7/Q8 fix:** Tabulka XSD atributů u `kc_zd6p` říkala "§16a", ale Legislative notes a XML generátor správně říkaly "§38f / Příloha 3" (alokace §6 portionu pro zápočet zahraniční daně). Sjednoceno: tabulka teď uvádí "§38f / Příloha 3" s explicitní poznámkou, že **nesouvisí s §16a** (Příloha 4 — samostatný základ z vybraných zahraničních příjmů) ani s progresivní sazbou §16 odst. 1. + +### v3 (2026-05-02) — second-round review feedback + +- **N5 fix:** `Section6CertsBonus++` v Recalculate odstraněn. `potv_dazvyh` má reflektovat počet samostatných formulářů "Potvrzení o vyplaceném daňovém bonusu" (`EmploymentDocument.Kind=bonus`), ne počet advance certifikátů s vyplaceným bonusem. V MVP zůstává 0; samostatný upload UI přidán do Out of Scope. +- **N4 fix:** Frontend sekce na detail income return řádek nahrazen `ř.76` → `ř.89`. Pozůstatek z první verze. +- **N2/N7 fix:** "doplatek z RZ z ř.19" v tabulce odstraněno — ř.19 v Potvrzení vzor 33 neexistuje. Doplatek z RZ je v ř.13 (per XSD doc na `kc_vyplbonus`). +- **N1 fix:** Test description "monthly bonus subtraction from child benefit" přepsán na "ChildBenefit zůstává nezměněn i když MonthlyBonusPaid > 0 (regression for K3)". +- **N6 fix:** Test "§16a limit warning" → "§16 progressive rate warning when totalBase > 36× průměrná mzda". +- Integration test: `potv_dazvyh=1` → `potv_dazvyh=0` (důsledek N5). + +### v2 (2026-05-02) — review feedback + +- **K2 fix:** Vyplacený měsíční bonus přemapován z chybně přiřazeného ř.76 na **ř.89** (`kc_vyplbonus`); ř.76 je computed = ř.72 − ř.73 (nárok na bonus). +- **K3 fix:** Odstraněn chybný kód `itr.ChildBenefit -= itr.Section6MonthlyBonusPaid` který by způsobil dvojí započtení. Vyplacené bonusy jsou samostatná hodnota na ř.89, ChildBenefit (ř.72) zůstává plně vypočtený. +- **K4 fix:** `foreign_income` přejmenováno na `income_without_advance` (DB sloupec, doménové pole, JSON output, label v UI) — ř.35 zahrnuje i příjmy zaměstnanců zahraničních zastupitelských úřadů v ČR podle §38c, ne jen ze zahraničí. +- **§16/§16a oprava:** Progresivní 23 % je v § 16 odst. 1, ne v §16a. §16a je samostatný institut Přílohy 4 (vybrané zahraniční příjmy) — terminologie a nápověda upraveny. `WarningSection16aManual` přejmenován na `WarningProgressiveRateReview`. +- **Lhůta vystavení Potvrzení:** § 38j odst. 3 ZDP — "do 10 dnů od žádosti", nikoliv "do 1. března". Help-content opraven. +- **OCR prompt:** monthly_bonus_paid extrahuje **ř.5 + ř.13** Potvrzení vzor 33 (per oficiální pokyny str. 4 a XSD doc na `kc_vyplbonus`), ne jen ř.13. +- **K1 (ř.32) ponecháno beze změny:** ověřeno přímo z PDF pokynů 2025 vzor 33 (str. 2): "**ř. 32 Neobsazeno**". Srážkové příjmy zahrnuté do DAP jdou do ř.31. Stale text v XSD docstringu na `kc_prij6` ("do ř. 32") je z předchozích vzorů — RFC řádek explicitně dokumentuje tuto nesrovnalost. diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index fe7df04..1be75ff 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -1056,6 +1056,20 @@ export interface IncomeTaxReturn { other_income_expenses: number; other_income_exempt: number; other_income_net: number; + // §6 employment income (DPC/DPP/HPP) aggregates + section6_gross_income?: number; + section6_income_without_advance?: number; + section6_foreign_tax?: number; + section6_tax_base?: number; + section6_advance_withheld?: number; + section6_withholding_credited?: number; + section6_monthly_bonus_paid?: number; + section6_certs_advance?: number; + section6_certs_withholding?: number; + section6_certs_bonus?: number; + // Backend-emitted advisory warning codes (e.g. "progressive_rate_review"). + // Empty array or absent field = no warnings. + warnings?: string[]; has_xml: boolean; status: string; filed_at: string | null; @@ -1661,6 +1675,105 @@ export const investmentsApi = { } }; +// --- Employment Income (§6) Types --- + +export type EmploymentDocumentKind = 'advance' | 'withholding' | 'bonus'; +export type CertificateType = 'advance' | 'withholding'; +export type ContractType = 'dpc' | 'dpp' | 'hpp' | 'other'; + +export interface EmploymentDocument { + id: number; + year: number; + kind: EmploymentDocumentKind; + filename: string; + content_type: string; + size: number; + extraction_status: 'pending' | 'extracted' | 'failed'; + extraction_error?: string; + created_at: string; +} + +export interface EmploymentCertificate { + id: number; + year: number; + document_id?: number; + certificate_type: CertificateType; + employer_name: string; + employer_ico: string; + employer_address?: string; + contract_type: ContractType; + period_from: string; // YYYY-MM-DD + period_to: string; + gross_income_czk: number; + income_without_advance_czk: number; + foreign_tax_paid_czk: number; + advance_tax_withheld_czk: number; + annual_settlement_refund_czk: number; + monthly_bonus_paid_czk: number; + withheld_final_tax_czk: number; + include_withholding_in_dap: boolean; + notes?: string; + confidence?: number; + status: 'draft' | 'confirmed'; + created_at: string; + updated_at: string; +} + +// --- Employment Income (§6) API --- + +export const employmentApi = { + async uploadDocument( + year: number, + kind: EmploymentDocumentKind, + file: File + ): Promise { + const formData = new FormData(); + formData.append('file', file); + const response = await fetch(`${API_BASE}/tax/employment/documents?year=${year}&kind=${kind}`, { + method: 'POST', + body: formData + }); + if (!response.ok) { + let body: unknown; + try { + body = await response.json(); + } catch { + /* ignore */ + } + throw new ApiError(response.status, response.statusText, body); + } + return response.json(); + }, + extractDocument(id: number) { + return post(`/tax/employment/documents/${id}/extract`, {}); + }, + listDocuments(year: number) { + return get(`/tax/employment/documents?year=${year}`); + }, + deleteDocument(id: number) { + return del(`/tax/employment/documents/${id}`); + }, + + listCertificates(year: number) { + return get(`/tax/employment/certificates?year=${year}`); + }, + getCertificate(id: number) { + return get(`/tax/employment/certificates/${id}`); + }, + createCertificate(cert: Partial) { + return post('/tax/employment/certificates', cert); + }, + updateCertificate(id: number, cert: Partial) { + return put(`/tax/employment/certificates/${id}`, cert); + }, + confirmCertificate(id: number) { + return post(`/tax/employment/certificates/${id}/confirm`, {}); + }, + deleteCertificate(id: number) { + return del(`/tax/employment/certificates/${id}`); + } +}; + // --- Fakturoid Import Types --- export interface FakturoidImportResult { diff --git a/frontend/src/lib/components/EmploymentCertificateEditor.svelte b/frontend/src/lib/components/EmploymentCertificateEditor.svelte new file mode 100644 index 0000000..6adb115 --- /dev/null +++ b/frontend/src/lib/components/EmploymentCertificateEditor.svelte @@ -0,0 +1,468 @@ + + +{#if open} + + +{/if} diff --git a/frontend/src/lib/data/help-content.test.ts b/frontend/src/lib/data/help-content.test.ts index bed20d9..1c56889 100644 --- a/frontend/src/lib/data/help-content.test.ts +++ b/frontend/src/lib/data/help-content.test.ts @@ -6,7 +6,7 @@ describe('help-content', () => { const topicIds = Object.keys(helpTopics) as HelpTopicId[]; it('has all expected topics', () => { - expect(topicIds.length).toBe(69); + expect(topicIds.length).toBe(84); }); it.each(topicIds)('topic "%s" has non-empty title', (id) => { @@ -94,7 +94,22 @@ describe('help-content', () => { 'nova-zaloha', 'ztpp', 'fifo-prepocet', - 'sleva-na-manzela' + 'sleva-na-manzela', + 'zavisla-cinnost-s6', + 'dpc-dpp-hpp', + 'potvrzeni-zalohove', + 'potvrzeni-srazkove', + 'srazkova-do-dap', + 'radek-31-prijmy-s6', + 'radek-33-zahranicni-dan', + 'radek-34-dilci-zaklad-s6', + 'radek-84-srazene-zalohy', + 'radek-87-srazena-dan', + 'radek-89-vyplacene-bonusy', + 'rocni-zuctovani-rz', + 'superhruba-mzda-zrusena', + 'progresivni-sazba-23', + 'samostatny-zaklad-16a' ]; expect(topicIds.sort()).toEqual(expectedIds.sort()); }); diff --git a/frontend/src/lib/data/help-content.ts b/frontend/src/lib/data/help-content.ts index b662fa3..03269a8 100644 --- a/frontend/src/lib/data/help-content.ts +++ b/frontend/src/lib/data/help-content.ts @@ -67,7 +67,22 @@ export type HelpTopicId = | 'nova-zaloha' | 'ztpp' | 'fifo-prepocet' - | 'sleva-na-manzela'; + | 'sleva-na-manzela' + | 'zavisla-cinnost-s6' + | 'dpc-dpp-hpp' + | 'potvrzeni-zalohove' + | 'potvrzeni-srazkove' + | 'srazkova-do-dap' + | 'radek-31-prijmy-s6' + | 'radek-33-zahranicni-dan' + | 'radek-34-dilci-zaklad-s6' + | 'radek-84-srazene-zalohy' + | 'radek-87-srazena-dan' + | 'radek-89-vyplacene-bonusy' + | 'rocni-zuctovani-rz' + | 'superhruba-mzda-zrusena' + | 'progresivni-sazba-23' + | 'samostatny-zaklad-16a'; export interface HelpTopic { title: string; @@ -516,6 +531,113 @@ const staticTopics: Record = { 'FIFO (First In, First Out) je metoda pro určení nabývací ceny při prodeji cenných papírů. Znamená, že při prodeji se jako první "spotřebují" nejstarší nakoupené kusy.\n\nPříklad: Koupili jste 10 ks za 100 Kč a pak 10 ks za 150 Kč. Pokud prodáte 10 ks, nabývací cena bude 100 Kč (použijí se první nakoupené kusy).\n\nFIFO metoda je pro OSVČ jediná povolená metoda.', legal: 'FIFO metoda je jediná přípustná metoda oceňování pro fyzické osoby při prodeji cenných papírů dle § 10 odst. 4 zákona č. 586/1992 Sb. a pokynu GFŘ-D-22. Při FIFO se přiřadí výdaj k přímo identifikovatelnému nákupu, nebo se použije nejstarší nepřiřazený nákup. Náklady na poplatky brokera jsou součástí nabývací ceny.' + }, + + // §6 Employment Income (RFC-016) + 'zavisla-cinnost-s6': { + title: 'Závislá činnost (§6)', + simple: + 'Zde nahrajte Potvrzení o zdanitelných příjmech, které vám vystavil zaměstnavatel za DPČ, DPP nebo hlavní pracovní poměr. Aplikace z něj vyextrahuje údaje a propíše je do řádků 31, 33, 34, 84 a 87 vašeho daňového přiznání.\n\nKaždé Potvrzení od jiného zaměstnavatele uložte zvlášť. Aplikace pozná dvě varianty -- "zálohové" (formulář 25 5460 vzor 33) a "srážkové" (25 5460/A vzor 12). U srážkového se rozhodnete, jestli ho chcete zahrnout do přiznání nebo ne.', + legal: + 'Příjmy ze závislé činnosti definuje § 6 zákona č. 586/1992 Sb. o daních z příjmů. Plátce daně je povinen vystavit Potvrzení do 10 dnů od podání žádosti poplatníkem podle § 38j odst. 3 ZDP.\n\nDílčí základ daně podle § 6 = úhrn příjmů snížený o daň zaplacenou v zahraničí (§ 6 odst. 13). Od 1. 1. 2021 se nepřičítá pojistné odvedené zaměstnavatelem (zrušení superhrubé mzdy zákonem 609/2020 Sb.). Progresivní sazba 23 % je v § 16 odst. 1 ZDP, NIKOLIV v § 16a (ten je samostatný institut samostatného základu daně z vybraných zahraničních příjmů -- Příloha č. 4 DAP).' + }, + 'dpc-dpp-hpp': { + title: 'Typy pracovních smluv (DPČ/DPP/HPP)', + simple: + 'V Česku existují tři běžné typy pracovněprávního vztahu:\n\n- HPP (hlavní pracovní poměr) -- klasická pracovní smlouva, plná pracovní doba.\n- DPČ (Dohoda o pracovní činnosti) -- maximálně poloviční úvazek průměrně za rok, vhodné pro brigády a vedlejšák.\n- DPP (Dohoda o provedení práce) -- max 300 hodin u jednoho zaměstnavatele za rok; specifické daňové režimy podle limitů odměny.\n\nPro DPFO je rozlišení důležité hlavně proto, že DPP a DPČ často podléhají srážkové dani, kterou se rozhodnete zahrnout nebo nezahrnout do přiznání.', + legal: + 'Pracovní poměr je upraven v § 30 a násl. zákoníku práce (z. č. 262/2006 Sb.). Dohoda o provedení práce: § 75 ZP, max 300 hodin u jednoho zaměstnavatele za kalendářní rok. Dohoda o pracovní činnosti: § 76 ZP. Daňový režim odměn z DPP a DPČ se odvíjí od podpisu prohlášení k dani podle § 38k ZDP a od limitů uvedených v § 6 odst. 4 ZDP (rozhodný příjem pro účast na nemocenském).' + }, + 'potvrzeni-zalohove': { + title: 'Potvrzení o zdanitelných příjmech (vzor 33)', + simple: + 'Tento formulář dostáváte od zaměstnavatele, pokud vám sráží zálohy na daň (typicky DPČ s podepsaným prohlášením, HPP). Najdete na něm úhrn vašich příjmů (ř. 2 + 4), úhrn měsíčních daňových bonusů (ř. 5 + 13) a sražené zálohy po slevách (ř. 8).\n\nPokud Potvrzení nemáte, máte právo o něj zaměstnavatele písemně požádat -- vystavit vám ho musí do 10 dnů od žádosti (§ 38j odst. 3 ZDP).\n\nDo aplikace stačí nahrát PDF nebo fotku -- AI to přečte za vás. Vždycky si ale zkontrolujte vyextrahované hodnoty, OCR může udělat chybu.', + legal: + 'Formulář MFin 25 5460 vzor č. 33 vydává Ministerstvo financí ČR pro zdaňovací období 2025. Plátce daně je povinen vystavit Potvrzení do 10 dnů od podání žádosti poplatníkem podle § 38j odst. 3 ZDP. Údaje z něj se přenášejí do oddílu 1 Přiznání k DPFO (řádky 31, 33, 34, 36) a oddílu 7 (řádky 84 a 89). Roční zúčtování provádí zaměstnavatel podle § 38ch -- pokud bylo provedeno, sražené zálohy na ř. 84 se snižují o vrácený přeplatek.' + }, + 'potvrzeni-srazkove': { + title: 'Potvrzení o vyplacených příjmech a sražené dani (vzor 12)', + simple: + 'Tento formulář (25 5460/A vzor č. 12) dostáváte od zaměstnavatele, pokud vám z odměny (typicky DPP do limitu nebo DPČ bez prohlášení k dani) srazil daň zvláštní sazbou rovnou 15 %. Daň je tím vypořádána a vy nemáte povinnost příjem v přiznání uvádět.\n\nMůžete se ale dobrovolně rozhodnout tyto srážkově zdaněné příjmy do přiznání zahrnout -- pak si srážkovou daň započtete proti vypočtené dani z přiznání. Pokud se tak rozhodnete, musíte zahrnout VŠECHNY srážkově zdaněné příjmy daného typu (§ 38g odst. 6 ZDP).', + legal: + 'Formulář MFin 25 5460/A vzor č. 12 -- Potvrzení o vyplacených příjmech ze závislé činnosti, sražené dani vybírané srážkou podle zvláštní sazby daně. Srážková daň 15 % podle § 36 odst. 2 ZDP. Volitelné zahrnutí do DAP umožňuje § 36 odst. 6 a 7 ZDP, podmíněno § 38g odst. 6 ZDP -- musí se zahrnout veškeré srážkově zdaněné příjmy daného typu.' + }, + 'srazkova-do-dap': { + title: 'Zahrnutí srážkové daně do přiznání', + simple: + 'Pokud zaškrtnete "Zahrnout do daňového přiznání", úhrn srážkových příjmů z tohoto Potvrzení se přičte na ř. 31 a sražená daň na ř. 87 -- započte se proti vypočtené dani.\n\nDŮLEŽITÉ: pokud se rozhodnete zahrnout, musíte podle § 38g odst. 6 ZDP zahrnout VŠECHNY srážkově zdaněné příjmy daného typu (DPP/DPČ) za celý rok -- nemůžete si vybrat jen jeden výhodný formulář.', + legal: + 'Volitelné zahrnutí podle § 36 odst. 6 a 7 ZDP. Podmínka kompletního zahrnutí je v § 38g odst. 6 ZDP: "do daňového přiznání zahrne veškeré tyto příjmy". Sražená daň se uplatní jako sražená daň podle § 6 (ř. 87 DAP), nikoliv jako daňová záloha (ř. 84). Po zahrnutí se zákonná povinnost podat DAP nemění -- pokud poplatník neměl jinou povinnost ji podat, zahrnutí ji nezakládá.' + }, + 'radek-31-prijmy-s6': { + title: 'ř.31 Úhrn příjmů §6', + simple: + 'Řádek 31 přiznání obsahuje úhrn všech vašich příjmů ze závislé činnosti od všech zaměstnavatelů. Sčítají se sem:\n\n- Příjmy z Potvrzení vzor 33 (ř. 2 + ř. 4 -- úhrn zúčtovaných příjmů a další zdanitelné příjmy)\n- Pokud jste se rozhodli zahrnout srážkové příjmy, i ř. 2 z Potvrzení vzor 12\n\nOd 1. 1. 2021 se zde NEpřičítá povinné pojistné odvedené zaměstnavatelem (zrušení superhrubé mzdy).', + legal: + 'Řádek 31 odpovídá XSD atributu kc_prij6 v elementu VetaO. Definice viz pokyny k vyplnění DPFO za rok 2025, oddíl 1, str. 2. Zahrnuje všechny příjmy ze závislé činnosti od všech plátců, včetně příjmů ze zdrojů v zahraničí (§ 6 odst. 13 ZDP) a včetně srážkových příjmů zařazených do DAP podle § 36 odst. 6/7. Hodnotu pojistného odvedeného zaměstnavatelem k ZD nepřičítat -- superhrubá mzda zrušena zákonem 609/2020 Sb.' + }, + 'radek-33-zahranicni-dan': { + title: 'ř.33 Daň zaplacená v zahraničí', + simple: + 'Pokud máte příjem od zahraničního zaměstnavatele a zahraniční stát z něj sám strhl daň, můžete tuto zahraniční daň odečíst od základu daně §6 -- to se děje na řádku 33.\n\nOdečet se týká jen daně zaplacené podle § 6 odst. 13 ZDP a vyžaduje doložené potvrzení od zahraniční daňové správy.', + legal: + 'Řádek 33 odpovídá XSD atributu kc_dan_zah v elementu VetaO. Daň zaplacená v zahraničí v souvislosti s příjmy ze závislé činnosti podle § 6 odst. 13 zákona č. 586/1992 Sb. -- jen daňový rezident ČR. Doklad: úřední potvrzení zahraniční daňové správy o zaplacené dani. Alternativně lze využít zápočet podle § 38f a smluv o zamezení dvojího zdanění (Příloha č. 3 DAP).' + }, + 'radek-34-dilci-zaklad-s6': { + title: 'ř.34/36 Dílčí základ daně §6', + simple: + 'Řádek 34 (a stejná hodnota na ř. 36) je dílčí základ daně z příjmů ze závislé činnosti. Spočítá se jako:\n\n ř. 34 = ř. 31 (úhrn příjmů) − ř. 33 (zahraniční daň)\n\nTato hodnota se sčítá se základy daně z dalších oddílů (§7 podnikání, §8 kapitálové, §10 ostatní) pro výpočet celkové daně.', + legal: + 'Řádek 34 (i ř. 36) odpovídá XSD atributu kc_zd6 v elementu VetaO. Dílčí základ daně podle § 6 ZDP = úhrn příjmů snížený o daň zaplacenou v zahraničí podle § 6 odst. 13. Sčítá se s ostatními dílčími základy do souhrnného základu daně, na který se aplikuje § 16 odst. 1 (sazba 15 % / 23 %).' + }, + 'radek-84-srazene-zalohy': { + title: 'ř.84 Sražené zálohy zaměstnavateli', + simple: + 'Řádek 84 je úhrn všech záloh na daň, které vám zaměstnavatelé strhli ze mzdy během roku (po uplatnění slev na dani). Údaje pochází z ř. 8 každého Potvrzení vzor 33.\n\nPokud zaměstnavatel provedl roční zúčtování a vrátil vám přeplatek, hodnota se snižuje o ten vrácený přeplatek (čistá výše skutečně sražených záloh).', + legal: + 'Řádek 84 odpovídá XSD atributu kc_zalzavc v elementu VetaD. "Úhrn sražených záloh na daň z příjmů ze závislé činnosti (po slevách na dani)" -- ve vzoru Potvrzení č. 33 se jedná o údaj uvedený na ř. 8. Pokud bylo provedeno roční zúčtování podle § 38ch ZDP, snižuje se ř. 84 o vrácený přeplatek z RZ.' + }, + 'radek-87-srazena-dan': { + title: 'ř.87 Sražená daň §36 odst.6', + simple: + 'Řádek 87 obsahuje srážkovou daň z Potvrzení vzor 12, kterou jste se rozhodli zařadit do daňového přiznání. Tato částka se započte proti celkové dani vypočtené z přiznání.\n\nPokud srážkovou daň do přiznání nezahrnujete, nechte na 0 -- daň je již vypořádaná.', + legal: + 'Řádek 87 odpovídá XSD atributu kc_sraz_6_4 v elementu VetaD. Sražená daň podle § 36 odst. 6 ZDP zařazená do DAP daňovým rezidentem ČR. Volitelné zahrnutí do DAP podle § 36 odst. 6/7 ZDP, podmíněno povinností uvést všechny takové příjmy podle § 38g odst. 6.' + }, + 'radek-89-vyplacene-bonusy': { + title: 'ř.89 Úhrn vyplacených měsíčních daňových bonusů', + simple: + 'Řádek 89 je úhrn měsíčních daňových bonusů na děti, které vám zaměstnavatel vyplatil v průběhu roku (případně doplatil v ročním zúčtování). Údaje pochází z ř. 5 + ř. 13 Potvrzení vzor 33.\n\nTato hodnota se NEodečítá od nárokovaného ročního daňového zvýhodnění -- jde o samostatnou informativní položku pro vypořádání daňového bonusu se státem.', + legal: + 'Řádek 89 odpovídá XSD atributu kc_vyplbonus v elementu VetaD: "úhrn měsíčních daňových bonusů, které Vám zaměstnavatel vyplatil" -- z Potvrzení ř. 5 + ř. 13 (vzor 33). Podle § 35d ZDP zaměstnavatel vyplácí měsíční daňové bonusy podle § 35c. Vyplacený bonus se NEodčítá od nárokovaného ročního zvýhodnění (ř. 72/73/76); finální zúčtování probíhá mezi vypočtenou daní/bonusem a součtem ř. 84 + ř. 87 + ř. 89.' + }, + 'rocni-zuctovani-rz': { + title: 'Roční zúčtování záloh (RZ)', + simple: + 'Pokud jste o to zaměstnavatele požádali do 15. 2., mohl vám provést roční zúčtování záloh -- spočítá daň za celý rok se všemi slevami a vrátí vám případný přeplatek.\n\nPokud podáváte vlastní daňové přiznání (typicky kvůli OSVČ příjmům §7), v Potvrzení uvidíte vrácený přeplatek z RZ -- ten se odečte od sražených záloh na ř. 84, abyste přeplatek nezapočítali dvakrát.', + legal: + 'Roční zúčtování záloh je upraveno v § 38ch ZDP. Provádí ho zaměstnavatel na žádost poplatníka, pokud poplatník neměl povinnost podat DAP a podepsal prohlášení k dani. Vrácený přeplatek se v Potvrzení vzor 33 zachycuje samostatnou položkou; v DAP se sražené zálohy na ř. 84 uvádějí již po snížení o vrácený přeplatek (jinak by se přeplatek započetl podruhé).' + }, + 'superhruba-mzda-zrusena': { + title: 'Proč není pole na povinné pojistné', + simple: + 'Možná hledáte řádek 32 a pole pro povinné pojistné odvedené zaměstnavatelem -- "superhrubá mzda". Tento koncept byl od 1. 1. 2021 ZRUŠEN (zákonem 609/2020 Sb.).\n\nOd roku 2021 je základem daně samotná hrubá mzda -- pojistné odváděné zaměstnavatelem se k ní nepřičítá. Ř. 32 v aktuálním vzoru přiznání proto zůstává neobsazený.', + legal: + 'Superhrubá mzda byla zrušena novelou zákona č. 609/2020 Sb. s účinností od 1. 1. 2021. § 6 odst. 12 ZDP nyní stanoví, že základem daně z příjmů ze závislé činnosti jsou samotné příjmy zaměstnance, bez navýšení o pojistné odváděné zaměstnavatelem. Pokyny k vyplnění DPFO 2025 explicitně uvádějí "ř. 32 Neobsazeno" (str. 2).' + }, + 'progresivni-sazba-23': { + title: '§16 progresivní sazba 23 %', + simple: + 'Pokud váš celkový základ daně (součet všech dílčích §6+§7+§8+§10) přesáhne 36násobek průměrné mzdy, část nad limit je zdaněna sazbou 23 % místo 15 %. Pro rok 2025 je limit 1 676 052 Kč.\n\nVýpočet aplikace zvládne automaticky -- jen pokud se k limitu přibližujete, ověřte si, zda nemáte také zahraniční příjmy spadající pod §16a (samostatný základ daně, Příloha č. 4) -- to je samostatný institut.', + legal: + 'Progresivní sazba je v § 16 odst. 1 zákona č. 586/1992 Sb.: 15 % do 36násobku průměrné mzdy, 23 % nad. Pro rok 2025 limit 1 676 052 Kč (36 × 46 557 Kč). Vstupem je úhrn všech dílčích základů daně (§ 6 + § 7 + § 8 + § 10). Nezaměňovat s § 16a -- samostatný základ daně z vybraných zahraničních příjmů (Příloha č. 4 DAP, ř. 74a a ř. 414).' + }, + 'samostatny-zaklad-16a': { + title: '§16a samostatný základ daně', + simple: + '§16a je samostatný institut, který umožňuje zdanit některé zahraniční příjmy (např. dividendy ze smluvních států) zvláštní 15% sazbou v Příloze č. 4 přiznání -- mimo hlavní výpočet.\n\nDŮLEŽITÉ: §16a NEsouvisí s §6 závislou činností ani s progresivní sazbou 23 % (ta je v §16 odst. 1). Pokud máte jen české zaměstnání, §16a vás netýká.', + legal: + 'Samostatný základ daně podle § 16a ZDP -- vybrané zahraniční příjmy (typicky dividendy a podíly na zisku ze smluvních států), zdaněné zvláštní sazbou 15 %. Vykazuje se v Příloze č. 4 DAP (ř. 74a, ř. 414). Není součástí progresivní sazby podle § 16 odst. 1 a není dílčím základem daně podle § 6/§7/§8/§10. Tento institut je v aplikaci mimo MVP RFC-016.' } }; diff --git a/frontend/src/routes/tax/+page.svelte b/frontend/src/routes/tax/+page.svelte index 13d53d9..e03b80f 100644 --- a/frontend/src/routes/tax/+page.svelte +++ b/frontend/src/routes/tax/+page.svelte @@ -5,9 +5,11 @@ incomeTaxApi, socialInsuranceApi, healthInsuranceApi, + employmentApi, type IncomeTaxReturn, type SocialInsuranceOverview, type HealthInsuranceOverview, + type EmploymentCertificate, type TaxConstants } from '$lib/api/client'; import { loadTaxConstants } from '$lib/data/tax-constants.svelte'; @@ -26,21 +28,24 @@ let incomeTaxReturns = $state([]); let socialOverviews = $state([]); let healthOverviews = $state([]); + let employmentCertificates = $state([]); async function loadData() { loading = true; error = null; try { - const [itr, sio, hio, tc] = await Promise.all([ + const [itr, sio, hio, tc, emp] = await Promise.all([ incomeTaxApi.list(selectedYear), socialInsuranceApi.list(selectedYear), healthInsuranceApi.list(selectedYear), - loadTaxConstants(selectedYear) + loadTaxConstants(selectedYear), + employmentApi.listCertificates(selectedYear).catch(() => []) ]); incomeTaxReturns = itr ?? []; socialOverviews = sio ?? []; healthOverviews = hio ?? []; taxConstants = tc; + employmentCertificates = emp ?? []; } catch (e) { error = e instanceof Error ? e.message : 'Nepodařilo se načíst data'; } finally { @@ -48,6 +53,31 @@ } } + let employmentSection6Gross = $derived( + employmentCertificates + .filter((c) => c.status === 'confirmed') + .reduce((sum, c) => { + if (c.certificate_type === 'advance') return sum + c.gross_income_czk; + if (c.include_withholding_in_dap) return sum + c.gross_income_czk; + return sum; + }, 0) + ); + let employmentSection6Advance = $derived( + employmentCertificates + .filter((c) => c.status === 'confirmed' && c.certificate_type === 'advance') + .reduce((sum, c) => sum + c.advance_tax_withheld_czk - c.annual_settlement_refund_czk, 0) + ); + let employmentSection6Withholding = $derived( + employmentCertificates + .filter( + (c) => + c.status === 'confirmed' && + c.certificate_type === 'withholding' && + c.include_withholding_in_dap + ) + .reduce((sum, c) => sum + c.withheld_final_tax_czk, 0) + ); + let mounted = false; onMount(() => { loadData(); @@ -133,7 +163,7 @@ {#if loading} {:else} -
+

@@ -286,6 +316,59 @@ {/each} {/if} + + + +

+ Závislá činnost (§6) +

+ {#if employmentCertificates.length === 0} +

Zatím žádná Potvrzení

+
+ +
+ {:else} +
+
+ Počet Potvrzení + {employmentCertificates.length} +
+
+ ř. 31 (úhrn příjmů) + + {formatCZK(employmentSection6Gross * 100)} + +
+
+ ř. 84 (sražené zálohy) + + {formatCZK(employmentSection6Advance * 100)} + +
+
+ ř. 87 (srážková daň) + + {formatCZK(employmentSection6Withholding * 100)} + +
+
+
+ +
+ {/if} +

{/if}
diff --git a/frontend/src/routes/tax/employment/+page.svelte b/frontend/src/routes/tax/employment/+page.svelte new file mode 100644 index 0000000..a48b6c8 --- /dev/null +++ b/frontend/src/routes/tax/employment/+page.svelte @@ -0,0 +1,435 @@ + + + + Závislá činnost (§6) {selectedYear} - ZFaktury + + + + + +
+ ← Zpět na daně + +

+ Závislá činnost (§6) -- {selectedYear} + +

+ +
+ + + {selectedYear} + + +
+ + + + +
+ +

+ Nahrát zálohové Potvrzení (vzor 33) +

+

+ PDF nebo fotka. Aplikace přečte částky a otevře editor s předvyplněnými poli. +

+
+ +
+
+ + +

+ Nahrát srážkové Potvrzení (vzor 12) +

+

+ Pro DPP/DPČ se srážkovou daní. Sami se rozhodnete, jestli zahrnete do přiznání. +

+
+ +
+
+ + +

Zadat ručně

+

+ Pokud Potvrzení nemáte v digitální podobě nebo chcete data zadat sami. +

+
+ + +
+
+
+ + +
+ +
+ OCR vyžaduje aktivní AI poskytovatel v config.toml [ocr]. Pokud + OCR nefunguje nebo není nakonfigurované, použijte tlačítko „Zadat ručně". +
+
+ + +
+

Potvrzení za rok {selectedYear}

+ {#if loading} + + {:else if certificates.length === 0} +
+

Zatím žádná Potvrzení.

+

Nahrajte PDF nebo zadejte data ručně.

+
+ {:else} +
+ + + + + + + + + + + + + + + + {#each certificates as cert (cert.id)} + + + + + + + + + + + + {/each} + +
ZaměstnavatelObdobíTyp smlouvyTyp Potvrzeníř. 31 (úhrn)ř. 84 (zálohy)ř. 87 (srážka)StavAkce
{cert.employer_name} + {cert.period_from} – {cert.period_to} + {contractLabel(cert.contract_type)}{certTypeLabel(cert.certificate_type)}{fmt(cert.gross_income_czk)} + {cert.certificate_type === 'advance' + ? fmt(cert.advance_tax_withheld_czk - cert.annual_settlement_refund_czk) + : '—'} + + {cert.certificate_type === 'withholding' && cert.include_withholding_in_dap + ? fmt(cert.withheld_final_tax_czk) + : '—'} + + + {statusLabel(cert.status)} + + +
+ + {#if cert.status !== 'confirmed'} + + {/if} + +
+
+
+ {/if} +
+
+ + { + editorOpen = false; + }} + onsaved={onEditorSaved} +/> diff --git a/frontend/src/routes/tax/employment/page.test.ts b/frontend/src/routes/tax/employment/page.test.ts new file mode 100644 index 0000000..2bfa913 --- /dev/null +++ b/frontend/src/routes/tax/employment/page.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$app/state', () => ({ + page: { + params: {} as Record, + url: { pathname: '/tax/employment', searchParams: new URLSearchParams() } + } +})); + +import Page from './+page.svelte'; + +function jsonResponse(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: { 'Content-Type': 'application/json' } + }); +} + +function emptyResponse(status = 204) { + return new Response(null, { status, statusText: status === 204 ? 'No Content' : 'OK' }); +} + +const advanceCert = { + id: 10, + year: 2025, + document_id: 1, + certificate_type: 'advance' as const, + employer_name: 'Acme s.r.o.', + employer_ico: '12345678', + employer_address: 'Hlavni 1, Praha', + contract_type: 'dpc' as const, + period_from: '2025-01-01', + period_to: '2025-12-31', + gross_income_czk: 240000, + income_without_advance_czk: 0, + foreign_tax_paid_czk: 0, + advance_tax_withheld_czk: 36000, + annual_settlement_refund_czk: 0, + monthly_bonus_paid_czk: 0, + withheld_final_tax_czk: 0, + include_withholding_in_dap: false, + notes: '', + confidence: 0.92, + status: 'draft' as const, + created_at: '2026-04-01T00:00:00Z', + updated_at: '2026-04-01T00:00:00Z' +}; + +const uploadedDocument = { + id: 1, + year: 2025, + kind: 'advance' as const, + filename: 'potvrzeni.pdf', + content_type: 'application/pdf', + size: 12345, + extraction_status: 'pending' as const, + created_at: '2026-04-01T00:00:00Z' +}; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('Employment income page', () => { + it('renders empty state when no certificates', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([])); + + render(Page); + + await waitFor(() => { + expect(screen.getByTestId('empty-state')).toBeInTheDocument(); + }); + expect(screen.getByText(/Zatím žádná Potvrzení/)).toBeInTheDocument(); + }); + + it('renders OCR config advisory below upload tiles', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([])); + + render(Page); + + await waitFor(() => { + expect(screen.getByTestId('ocr-config-advisory')).toBeInTheDocument(); + }); + const advisory = screen.getByTestId('ocr-config-advisory'); + expect(advisory).toHaveAttribute('role', 'status'); + expect(advisory).toHaveTextContent(/OCR vyžaduje aktivní AI poskytovatel/); + expect(advisory).toHaveTextContent(/Zadat ručně/); + }); + + it('renders clickable "Zadat ručně" buttons', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([])); + + render(Page); + + await waitFor(() => { + expect(screen.getByTestId('manual-buttons-wrapper')).toBeInTheDocument(); + }); + const advanceBtn = screen.getByRole('button', { name: 'Zálohové' }); + const withholdingBtn = screen.getByRole('button', { name: 'Srážkové' }); + expect(advanceBtn).toBeInTheDocument(); + expect(withholdingBtn).toBeInTheDocument(); + expect(advanceBtn).not.toBeDisabled(); + expect(withholdingBtn).not.toBeDisabled(); + + // Click should open the manual editor (advance section appears). + await fireEvent.click(advanceBtn); + await waitFor(() => { + expect(screen.getByTestId('advance-section')).toBeInTheDocument(); + }); + }); + + it('lists certificates fetched on mount', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([advanceCert])); + + render(Page); + + await waitFor(() => { + expect(screen.getByText('Acme s.r.o.')).toBeInTheDocument(); + }); + expect(screen.getByText('DPČ')).toBeInTheDocument(); + expect(screen.getByText('Zálohové (vzor 33)')).toBeInTheDocument(); + }); + + it('opens manual editor for advance certificate', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([])); + + render(Page); + + await waitFor(() => { + expect(screen.getByTestId('manual-buttons-wrapper')).toBeInTheDocument(); + }); + + const manualBtn = screen.getByRole('button', { name: 'Zálohové' }); + await fireEvent.click(manualBtn); + + await waitFor(() => { + expect(screen.getByTestId('advance-section')).toBeInTheDocument(); + }); + expect(screen.getByTestId('employer-name-input')).toBeInTheDocument(); + }); + + it('switches form fields when toggling certificate type', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([])); + + render(Page); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Zálohové' })).toBeInTheDocument(); + }); + await fireEvent.click(screen.getByRole('button', { name: 'Zálohové' })); + + await waitFor(() => { + expect(screen.getByTestId('advance-section')).toBeInTheDocument(); + }); + + const typeSelect = screen.getByTestId('certificate-type-select') as HTMLSelectElement; + await fireEvent.change(typeSelect, { target: { value: 'withholding' } }); + + await waitFor(() => { + expect(screen.getByTestId('withholding-section')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('advance-section')).not.toBeInTheDocument(); + expect(screen.getByTestId('include-withholding-checkbox')).toBeInTheDocument(); + }); + + it('blocks save with invalid IČO and shows alert', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([])); + + render(Page); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Zálohové' })).toBeInTheDocument(); + }); + await fireEvent.click(screen.getByRole('button', { name: 'Zálohové' })); + + // Fill required fields with invalid IČO. + const nameInput = (await screen.findByTestId('employer-name-input')) as HTMLInputElement; + const icoInput = screen.getByTestId('employer-ico-input') as HTMLInputElement; + + await fireEvent.input(nameInput, { target: { value: 'Acme s.r.o.' } }); + await fireEvent.input(icoInput, { target: { value: '123' } }); + + // Bypass HTML5 native validation so the custom message can fire on click. + document.querySelectorAll('[required]').forEach((el) => el.removeAttribute('required')); + + const saveDraft = screen.getByRole('button', { name: 'Uložit jako koncept' }); + await fireEvent.click(saveDraft); + + await waitFor(() => { + expect(screen.getByText(/IČO musí být 8 číslic/)).toBeInTheDocument(); + }); + + // Should NOT have called the create endpoint. + const createCalls = mockFetch.mock.calls.filter( + ([url, init]) => + typeof url === 'string' && + url.endsWith('/tax/employment/certificates') && + (init as RequestInit | undefined)?.method === 'POST' + ); + expect(createCalls.length).toBe(0); + }); + + it('blocks save when period_from > period_to', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([])); + + render(Page); + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Zálohové' })).toBeInTheDocument(); + }); + await fireEvent.click(screen.getByRole('button', { name: 'Zálohové' })); + + const nameInput = (await screen.findByTestId('employer-name-input')) as HTMLInputElement; + const icoInput = screen.getByTestId('employer-ico-input') as HTMLInputElement; + const fromInput = screen.getByTestId('period-from-input') as HTMLInputElement; + const toInput = screen.getByTestId('period-to-input') as HTMLInputElement; + + await fireEvent.input(nameInput, { target: { value: 'Acme s.r.o.' } }); + await fireEvent.input(icoInput, { target: { value: '12345678' } }); + await fireEvent.input(fromInput, { target: { value: '2025-12-31' } }); + await fireEvent.input(toInput, { target: { value: '2025-01-01' } }); + + document.querySelectorAll('[required]').forEach((el) => el.removeAttribute('required')); + + await fireEvent.click(screen.getByRole('button', { name: 'Uložit jako koncept' })); + + await waitFor(() => { + expect(screen.getByText(/Datum "od" nesmí být pozdější/)).toBeInTheDocument(); + }); + }); + + it('shows OCR confidence badge in editor when draft has confidence', async () => { + // Render the editor directly via a draft that already includes confidence. + // Full upload flow exercises the file picker which depends on browser-only + // File constructor semantics; here we test that the badge renders correctly + // once the editor receives a draft with confidence set. + mockFetch.mockResolvedValueOnce(jsonResponse([advanceCert])); + + render(Page); + + await waitFor(() => { + expect(screen.getByText('Acme s.r.o.')).toBeInTheDocument(); + }); + + // Click Upravit on the row to open the editor with the cert (which has confidence: 0.92). + const editButtons = screen.getAllByRole('button', { name: 'Upravit' }); + await fireEvent.click(editButtons[0]); + + await waitFor(() => { + expect(screen.getByTestId('ocr-confidence-badge')).toBeInTheDocument(); + }); + expect(screen.getByTestId('ocr-confidence-badge')).toHaveTextContent(/92/); + }); +}); diff --git a/frontend/src/routes/tax/income/[id]/+page.svelte b/frontend/src/routes/tax/income/[id]/+page.svelte index 922394d..b96c687 100644 --- a/frontend/src/routes/tax/income/[id]/+page.svelte +++ b/frontend/src/routes/tax/income/[id]/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/state'; import { goto } from '$app/navigation'; import { incomeTaxApi, type IncomeTaxReturn, type TaxConstants } from '$lib/api/client'; + import type { HelpTopicId } from '$lib/data/help-content'; import { loadTaxConstants } from '$lib/data/tax-constants.svelte'; import { downloadFile } from '$lib/utils/download'; import { formatCZK } from '$lib/utils/money'; @@ -37,6 +38,19 @@ supplementary: 'Opravné' }; + // Map backend warning codes to UI labels + help-content topic IDs. + // New codes can be added without crashing existing UIs. + const WARNING_LABELS: Record = { + progressive_rate_review: { + title: 'Zkontrolujte progresivní sazbu daně 23 %', + helpTopicId: 'progresivni-sazba-23' + }, + withholding_partial_include: { + title: 'Částečné zahrnutí srážkové daně', + helpTopicId: 'srazkova-do-dap' + } + }; + onMount(() => { loadData(); }); @@ -217,6 +231,105 @@
+ + {#if data.warnings && data.warnings.length > 0} +
+ {#each data.warnings as warningCode (warningCode)} + {@const known = WARNING_LABELS[warningCode]} + + {/each} +
+ {/if} + + + {#if (data.section6_gross_income ?? 0) > 0} + +
+

+ §6 Závislá činnost +

+ + Upravit certifikáty + +
+
+
+ ř. 31 Úhrn příjmů §6 +
+
+ {formatCZK((data.section6_gross_income ?? 0) * 100)} +
+ +
+ ř. 33 Daň zaplacená v zahraničí + +
+
+ {formatCZK((data.section6_foreign_tax ?? 0) * 100)} +
+ +
+ ř. 34/36 Dílčí základ daně §6 + +
+
+ {formatCZK((data.section6_tax_base ?? 0) * 100)} +
+ +
+ ř. 84 Sražené zálohy zaměstnavateli + +
+
+ {formatCZK((data.section6_advance_withheld ?? 0) * 100)} +
+ +
+ ř. 87 Sražená daň §36 odst.6 +
+
+ {formatCZK((data.section6_withholding_credited ?? 0) * 100)} +
+ + {#if (data.section6_monthly_bonus_paid ?? 0) > 0} +
+ ř. 89 Vyplacené měsíční bonusy + +
+
+ {formatCZK((data.section6_monthly_bonus_paid ?? 0) * 100)} +
+ {/if} +
+
+ {/if} +

Příjmy a výdaje (Oddíl 7)

diff --git a/frontend/src/routes/tax/income/[id]/page.test.ts b/frontend/src/routes/tax/income/[id]/page.test.ts new file mode 100644 index 0000000..ee51e7d --- /dev/null +++ b/frontend/src/routes/tax/income/[id]/page.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/svelte'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$app/state', () => ({ + page: { + params: { id: '1' } as { id: string }, + url: { pathname: '/tax/income/1', searchParams: new URLSearchParams() } + } +})); + +import Page from './+page.svelte'; + +function jsonResponse(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + statusText: status === 200 ? 'OK' : 'Error', + headers: { 'Content-Type': 'application/json' } + }); +} + +const baseReturn = { + id: 1, + year: 2025, + filing_type: 'regular', + total_revenue: 50000000, + actual_expenses: 0, + flat_rate_percent: 60, + flat_rate_amount: 30000000, + used_expenses: 30000000, + tax_base: 20000000, + total_deductions: 0, + tax_base_rounded: 20000000, + tax_at_15: 3000000, + tax_at_23: 0, + total_tax: 3000000, + credit_basic: 3084000, + credit_spouse: 0, + credit_disability: 0, + credit_student: 0, + total_credits: 3084000, + tax_after_credits: 0, + child_benefit: 0, + tax_after_benefit: 0, + prepayments: 0, + tax_due: 0, + capital_income_gross: 0, + capital_income_tax: 0, + capital_income_net: 0, + other_income_gross: 0, + other_income_expenses: 0, + other_income_exempt: 0, + other_income_net: 0, + has_xml: false, + status: 'draft', + filed_at: null, + created_at: '2026-04-01T00:00:00Z', + updated_at: '2026-04-01T00:00:00Z' +}; + +const taxConstants = { + year: 2025, + basic_credit: 3084000, + spouse_credit: 2484000, + spouse_income_limit: 6800000, + student_credit: 0, + disability_credit_1: 0, + disability_credit_3: 0, + disability_ztpp: 0, + child_benefit_1: 0, + child_benefit_2: 0, + child_benefit_3_plus: 0, + max_child_bonus: 0, + progressive_threshold: 167605200, + flat_rate_caps: { '60': 120000000 }, + deduction_cap_mortgage: 0, + deduction_cap_pension: 0, + deduction_cap_life_insurance: 0, + deduction_cap_union: 0, + time_test_years: 3, + security_exemption_limit: 10000000 +}; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe('Income tax return detail - §6 panel', () => { + it('hides §6 panel when section6_gross_income is 0', async () => { + mockFetch.mockImplementation((url: string) => { + if (url.includes('/income-tax-returns/')) { + return Promise.resolve(jsonResponse(baseReturn)); + } + if (url.includes('/tax-constants/')) { + return Promise.resolve(jsonResponse(taxConstants)); + } + return Promise.resolve(jsonResponse({})); + }); + + render(Page); + + await waitFor(() => { + expect(screen.getByText('Daň z příjmů - 2025')).toBeInTheDocument(); + }); + + expect(screen.queryByText('§6 Závislá činnost')).not.toBeInTheDocument(); + }); + + it('shows §6 panel when section6_gross_income > 0', async () => { + const withSection6 = { + ...baseReturn, + section6_gross_income: 240000, + section6_foreign_tax: 0, + section6_tax_base: 240000, + section6_advance_withheld: 36000, + section6_withholding_credited: 0, + section6_monthly_bonus_paid: 0 + }; + + mockFetch.mockImplementation((url: string) => { + if (url.includes('/income-tax-returns/')) { + return Promise.resolve(jsonResponse(withSection6)); + } + if (url.includes('/tax-constants/')) { + return Promise.resolve(jsonResponse(taxConstants)); + } + return Promise.resolve(jsonResponse({})); + }); + + render(Page); + + await waitFor(() => { + expect(screen.getByText('§6 Závislá činnost')).toBeInTheDocument(); + }); + expect(screen.getByText('Upravit certifikáty')).toBeInTheDocument(); + // ř.31 row label. + expect(screen.getByText(/ř\. 31 Úhrn příjmů §6/)).toBeInTheDocument(); + // ř.84 row label. + expect(screen.getByText(/ř\. 84 Sražené zálohy/)).toBeInTheDocument(); + }); + + it('renders warning banner for known progressive_rate_review code', async () => { + const withWarning = { + ...baseReturn, + warnings: ['progressive_rate_review'] + }; + + mockFetch.mockImplementation((url: string) => { + if (url.includes('/income-tax-returns/')) { + return Promise.resolve(jsonResponse(withWarning)); + } + if (url.includes('/tax-constants/')) { + return Promise.resolve(jsonResponse(taxConstants)); + } + return Promise.resolve(jsonResponse({})); + }); + + render(Page); + + await waitFor(() => { + expect(screen.getByTestId('warnings-section')).toBeInTheDocument(); + }); + const banners = screen.getAllByTestId('warning-banner'); + expect(banners.length).toBe(1); + expect(banners[0]).toHaveAttribute('role', 'alert'); + expect(screen.getByText('Zkontrolujte progresivní sazbu daně 23 %')).toBeInTheDocument(); + }); + + it('hides warnings section when warnings array is empty', async () => { + const noWarnings = { ...baseReturn, warnings: [] }; + + mockFetch.mockImplementation((url: string) => { + if (url.includes('/income-tax-returns/')) { + return Promise.resolve(jsonResponse(noWarnings)); + } + if (url.includes('/tax-constants/')) { + return Promise.resolve(jsonResponse(taxConstants)); + } + return Promise.resolve(jsonResponse({})); + }); + + render(Page); + + await waitFor(() => { + expect(screen.getByText('Daň z příjmů - 2025')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('warnings-section')).not.toBeInTheDocument(); + }); + + it('renders raw code for unknown warning code without crashing', async () => { + const withUnknown = { + ...baseReturn, + warnings: ['some_future_unknown_code'] + }; + + mockFetch.mockImplementation((url: string) => { + if (url.includes('/income-tax-returns/')) { + return Promise.resolve(jsonResponse(withUnknown)); + } + if (url.includes('/tax-constants/')) { + return Promise.resolve(jsonResponse(taxConstants)); + } + return Promise.resolve(jsonResponse({})); + }); + + render(Page); + + await waitFor(() => { + expect(screen.getByTestId('warnings-section')).toBeInTheDocument(); + }); + const banner = screen.getByTestId('warning-banner'); + expect(banner).toHaveAttribute('role', 'alert'); + expect(banner).toHaveTextContent('some_future_unknown_code'); + }); + + it('shows ř.89 row only when monthly_bonus_paid > 0', async () => { + const withBonus = { + ...baseReturn, + section6_gross_income: 240000, + section6_foreign_tax: 0, + section6_tax_base: 240000, + section6_advance_withheld: 36000, + section6_withholding_credited: 0, + section6_monthly_bonus_paid: 15300 + }; + + mockFetch.mockImplementation((url: string) => { + if (url.includes('/income-tax-returns/')) { + return Promise.resolve(jsonResponse(withBonus)); + } + if (url.includes('/tax-constants/')) { + return Promise.resolve(jsonResponse(taxConstants)); + } + return Promise.resolve(jsonResponse({})); + }); + + render(Page); + + await waitFor(() => { + expect(screen.getByText(/ř\. 89 Vyplacené měsíční bonusy/)).toBeInTheDocument(); + }); + }); +}); diff --git a/internal/annualtaxxml/income_tax_gen.go b/internal/annualtaxxml/income_tax_gen.go index 7a085f6..25a4520 100644 --- a/internal/annualtaxxml/income_tax_gen.go +++ b/internal/annualtaxxml/income_tax_gen.go @@ -207,8 +207,23 @@ func GenerateIncomeTaxXML(itr *domain.IncomeTaxReturn, settings map[string]strin zd8 := ToWholeCZK(itr.CapitalIncomeNet) // ř.38 zd10 := ToWholeCZK(itr.OtherIncomeNet) // ř.40 uhrn := zd7 + zd8 + zd10 // ř.41 (no §9 rental income tracked yet) - zakldan23 := uhrn // ř.42 -- assumes §6 employment base = 0 - zakldanLoss := zakldan23 // ř.45 -- no carry-forward losses applied + + // §6 employment income inputs. + // kc_zd6 (ř.34/36) is computed by the service as kc_prij6 - kc_dan_zah and stored + // in itr.Section6TaxBase. We carry the precomputed value through to keep XML and + // detail-page rendering consistent. + prij6 := ToWholeCZK(itr.Section6GrossIncome) // ř.31 + prij6zahr := ToWholeCZK(itr.Section6IncomeWithoutAdvance) // ř.35 + danZah := ToWholeCZK(itr.Section6ForeignTax) // ř.33 + zd6 := ToWholeCZK(itr.Section6TaxBase) // ř.34/36 + + // XSD ř.42 critical control: "Pokud je ř.41 záporný, uveďte pouze hodnotu z ř.36" + // — when §7+§8+§10 sum to a loss, the consolidated tax base is just §6 alone. + zakldan23 := zd6 + if uhrn > 0 { + zakldan23 += uhrn + } + zakldanLoss := zakldan23 // ř.45 -- no carry-forward losses applied totalDeductions := ToWholeCZK(itr.TotalDeductions) zdsniz := zakldanLoss - totalDeductions // ř.55 if zdsniz < 0 { @@ -274,6 +289,13 @@ func GenerateIncomeTaxXML(itr *domain.IncomeTaxReturn, settings map[string]strin KcDbPoOdpd: dbPoOdpd, KcZalpred: ToWholeCZK(itr.Prepayments), KcZbyvpred: ToWholeCZK(itr.TaxDue), + // §6 employment income (RFC-016). + // kc_zalzavc / kc_sraz_6_4 / kc_vyplbonus all use omitempty; aggregates of zero + // (no §6 income) drop out and the resulting XML matches the pre-§6 baseline. + KcZalzavc: ToWholeCZK(itr.Section6AdvanceWithheld), + KcSraz64: ToWholeCZK(itr.Section6WithholdingCredited), + KcSrazRezEHP: 0, // ř.87a -- nerezident EU/EHP, MVP: not tracked + KcVyplBonus: ToWholeCZK(itr.Section6MonthlyBonusPaid), }, VetaP: DPFOVetaP{ Jmeno: settings["taxpayer_first_name"], @@ -290,6 +312,13 @@ func GenerateIncomeTaxXML(itr *domain.IncomeTaxReturn, settings map[string]strin CPracufo: strings.TrimSpace(settings["c_pracufo"]), }, VetaO: &DPFOVetaO{ + // §6 employment income (RFC-016) -- omitempty drops zero values for the + // no-employment-income case, preserving pre-§6 XML output. + KcPrij6: prij6, + KcPrij6zahr: prij6zahr, + KcDanZah: danZah, + KcZd6: zd6, + KcZd6p: 0, // §38f Příloha 3 alokace -- MVP: 0 (omitted) KcZd7: zd7, KcZakldan8: zd8, KcZd10: zd10, @@ -311,6 +340,11 @@ func GenerateIncomeTaxXML(itr *domain.IncomeTaxReturn, settings map[string]strin VetaA: buildChildRows(children), VetaB: &DPFOVetaB{ Priloha1: "1", + // §6 attachment counts (RFC-016) -- all zero for taxpayers without + // employment income; omitempty keeps the baseline XML untouched. + PotvZam: itr.Section6CertsAdvance, + Potv36: itr.Section6CertsWithholding, + PotvDazvyh: itr.Section6CertsBonus, }, VetaT: buildPriloha1(itr, settings, revenue, expenses, zd7Signed), } diff --git a/internal/annualtaxxml/income_tax_gen_test.go b/internal/annualtaxxml/income_tax_gen_test.go index 92f2f71..97581c3 100644 --- a/internal/annualtaxxml/income_tax_gen_test.go +++ b/internal/annualtaxxml/income_tax_gen_test.go @@ -655,3 +655,166 @@ func TestPadNACEto6(t *testing.T) { } } } + +// section6BaseSettings returns a minimal valid settings map for §6 tests. +func section6BaseSettings() map[string]string { + return map[string]string{ + "financni_urad_code": "451", + "taxpayer_first_name": "Jan", + "taxpayer_last_name": "Novak", + "taxpayer_birth_number": "8001011234", + "dic": "CZ8001011234", + "taxpayer_street": "Hlavni", + "taxpayer_house_number": "42", + "taxpayer_city": "Praha", + "taxpayer_postal_code": "11000", + } +} + +// TestIncomeTaxXML_Section6Advance covers the typical advance case: two zálohové +// Potvrzení (vzor 33) aggregating to 240 000 Kč gross, 36 000 Kč withheld advances, +// 15 300 Kč paid monthly bonuses. potv_dazvyh stays 0 in MVP because we don't yet +// support the standalone "Potvrzení o vyplaceném daňovém bonusu" form. +func TestIncomeTaxXML_Section6Advance(t *testing.T) { + itr := &domain.IncomeTaxReturn{ + Year: 2025, + FilingType: domain.FilingTypeRegular, + TotalRevenue: domain.NewAmount(0, 0), + Section6GrossIncome: domain.NewAmount(240000, 0), // ř.31 + Section6TaxBase: domain.NewAmount(240000, 0), // ř.34/36 (no foreign tax) + Section6AdvanceWithheld: domain.NewAmount(36000, 0), // ř.84 + Section6MonthlyBonusPaid: domain.NewAmount(15300, 0), // ř.89 + Section6CertsAdvance: 2, + // Section6CertsBonus stays 0 -- standalone bonus form OOS in MVP + } + xmlData, err := GenerateIncomeTaxXML(itr, section6BaseSettings(), nil) + if err != nil { + t.Fatalf("GenerateIncomeTaxXML: %v", err) + } + for _, want := range []string{ + `kc_prij6="240000"`, + `kc_zd6="240000"`, + `kc_zalzavc="36000"`, + `kc_vyplbonus="15300"`, + `potv_zam="2"`, + } { + if !bytes.Contains(xmlData, []byte(want)) { + t.Errorf("expected XML to contain %q, got:\n%s", want, xmlData) + } + } + // potv_dazvyh stays 0 (omitempty drops it) in MVP -- attribute must NOT appear. + if bytes.Contains(xmlData, []byte("potv_dazvyh=")) { + t.Errorf("potv_dazvyh must be omitted in MVP, got:\n%s", xmlData) + } + // kc_zakldan23 (ř.42) must equal kc_zd6 alone -- there is no §7/§8/§10 income. + if !bytes.Contains(xmlData, []byte(`kc_zakldan23="240000"`)) { + t.Errorf("expected kc_zakldan23=240000 (= kc_zd6 with no §7/8/10 income), got:\n%s", xmlData) + } +} + +// TestIncomeTaxXML_Section6Withholding covers the user opting to include srážková +// daň in DAP per §36 odst. 6/7. The withholding amount must land on kc_sraz_6_4 (ř.87) +// and the attachment count potv_36 must be set. +func TestIncomeTaxXML_Section6Withholding(t *testing.T) { + itr := &domain.IncomeTaxReturn{ + Year: 2025, + FilingType: domain.FilingTypeRegular, + TotalRevenue: domain.NewAmount(0, 0), + Section6GrossIncome: domain.NewAmount(50000, 0), + Section6TaxBase: domain.NewAmount(50000, 0), + Section6WithholdingCredited: domain.NewAmount(7500, 0), // ř.87 -- §36 odst.6 sražená daň + Section6CertsWithholding: 1, + } + xmlData, err := GenerateIncomeTaxXML(itr, section6BaseSettings(), nil) + if err != nil { + t.Fatalf("GenerateIncomeTaxXML: %v", err) + } + for _, want := range []string{ + `kc_prij6="50000"`, + `kc_zd6="50000"`, + `kc_sraz_6_4="7500"`, + `potv_36="1"`, + } { + if !bytes.Contains(xmlData, []byte(want)) { + t.Errorf("expected XML to contain %q, got:\n%s", want, xmlData) + } + } +} + +// TestIncomeTaxXML_Section6OnlyNegativeSection7 covers the XSD critical control on +// kc_zakldan23 (ř.42): "Pokud je ř.41 záporný, uveďte pouze hodnotu z ř.36". When +// the §7+§8+§10 sum is negative, the consolidated tax base equals §6 alone. +func TestIncomeTaxXML_Section6OnlyNegativeSection7(t *testing.T) { + itr := &domain.IncomeTaxReturn{ + Year: 2025, + FilingType: domain.FilingTypeRegular, + TotalRevenue: domain.NewAmount(100000, 0), // §7 income 100k + UsedExpenses: domain.NewAmount(180000, 0), // §7 expenses 180k -> loss 80k + Section6GrossIncome: domain.NewAmount(300000, 0), + Section6TaxBase: domain.NewAmount(300000, 0), + } + xmlData, err := GenerateIncomeTaxXML(itr, section6BaseSettings(), nil) + if err != nil { + t.Fatalf("GenerateIncomeTaxXML: %v", err) + } + // kc_zakldan23 (ř.42) must equal kc_zd6 (300000) -- the §7 loss does NOT add. + if !bytes.Contains(xmlData, []byte(`kc_zakldan23="300000"`)) { + t.Errorf("expected kc_zakldan23=300000 (= kc_zd6, ř.41 negative dropped), got:\n%s", xmlData) + } + if !bytes.Contains(xmlData, []byte(`kc_zd6="300000"`)) { + t.Errorf("expected kc_zd6=300000, got:\n%s", xmlData) + } + // kc_dztrata (ř.61) must hold the §7 loss (80000), unaffected by §6. + if !bytes.Contains(xmlData, []byte(`kc_dztrata="80000"`)) { + t.Errorf("expected kc_dztrata=80000 (§7 loss), got:\n%s", xmlData) + } +} + +// TestIncomeTaxXML_BonusReportedSeparately verifies that MonthlyBonusPaid (ř.89) is +// reported separately on kc_vyplbonus and does NOT reduce ChildBenefit (ř.72) or +// the computed bonus on ř.76. Regression for K3 in RFC-016 v2. +// +// Also asserts kc_zbyvpred (ř.91) reflects the bonus reconciliation: the user +// claimed 20 000 Kč bonus but employer only paid out 10 000 Kč → user still +// has 10 000 Kč refund coming on the DAP, i.e. kc_zbyvpred = −10 000. +func TestIncomeTaxXML_BonusReportedSeparately(t *testing.T) { + itr := &domain.IncomeTaxReturn{ + Year: 2025, + FilingType: domain.FilingTypeRegular, + TotalRevenue: domain.NewAmount(0, 0), + Section6GrossIncome: domain.NewAmount(150000, 0), + Section6TaxBase: domain.NewAmount(150000, 0), + Section6MonthlyBonusPaid: domain.NewAmount(10000, 0), // ř.89 vyplacený bonus zaměstnavatelem + Section6CertsAdvance: 1, + ChildBenefit: domain.NewAmount(20000, 0), // ř.72 nárok -- must remain unchanged + // Tax 0 (no §16 calc setup here) so all of ChildBenefit becomes ř.76 bonus. + TotalTax: domain.NewAmount(0, 0), + // TaxAfterBenefit = −20 000 (user has full claim). TaxDue (ř.91) = + // −20 000 + 10 000 (ř.89) = −10 000 — see calc.CalculateIncomeTax. + // The XML generator only echoes itr.TaxDue here; assert the wired + // value flows through to kc_zbyvpred. + TaxDue: -domain.NewAmount(10000, 0), + } + xmlData, err := GenerateIncomeTaxXML(itr, section6BaseSettings(), nil) + if err != nil { + t.Fatalf("GenerateIncomeTaxXML: %v", err) + } + // kc_vyplbonus (ř.89) reflects the employer-paid bonus. + if !bytes.Contains(xmlData, []byte(`kc_vyplbonus="10000"`)) { + t.Errorf("expected kc_vyplbonus=10000, got:\n%s", xmlData) + } + // kc_dazvyhod (ř.72) keeps the full ChildBenefit -- no double-counting. + if !bytes.Contains(xmlData, []byte(`kc_dazvyhod="20000"`)) { + t.Errorf("expected kc_dazvyhod=20000 (ChildBenefit unchanged by ř.89), got:\n%s", xmlData) + } + // ř.76 (kc_danbonus) = ChildBenefit when tax after credits is 0; verify it equals 20000. + if !bytes.Contains(xmlData, []byte(`kc_danbonus="20000"`)) { + t.Errorf("expected kc_danbonus=20000 (ř.76 nárok unchanged), got:\n%s", xmlData) + } + // kc_zbyvpred (ř.91) reflects ř.89 reconciliation: user has 10 000 Kč + // refund coming back from the státu (employer paid 10k, user entitled + // to 20k → 10k still owed). Negative TaxDue serialises with leading "-". + if !bytes.Contains(xmlData, []byte(`kc_zbyvpred="-10000"`)) { + t.Errorf("expected kc_zbyvpred=-10000 (ř.91 reflects ř.72 − ř.89 = −10 000), got:\n%s", xmlData) + } +} diff --git a/internal/annualtaxxml/income_tax_types.go b/internal/annualtaxxml/income_tax_types.go index 4ed8162..c531c7f 100644 --- a/internal/annualtaxxml/income_tax_types.go +++ b/internal/annualtaxxml/income_tax_types.go @@ -80,6 +80,13 @@ type DPFOVetaD struct { KcDbPoOdpd int64 `xml:"kc_db_po_odpd,attr"` // ř. 77a -- daňový bonus po odpočtu daně (= ř.76 - ř.75, min 0) KcZalpred int64 `xml:"kc_zalpred,attr"` // ř. 84 -- úhrn sražených záloh KcZbyvpred int64 `xml:"kc_zbyvpred,attr"` // ř. 91 -- zbývá doplatit / přeplatek + + // §6 employment income (závislá činnost) -- omitempty so taxpayers without + // employment income emit XML identical to the pre-§6-support output. + KcZalzavc int64 `xml:"kc_zalzavc,attr,omitempty"` // ř. 84 §6 -- sražené zálohy zaměstnavateli (po RZ refund) + KcSraz64 int64 `xml:"kc_sraz_6_4,attr,omitempty"` // ř. 87 -- sražená daň §36 odst.6 (rezident ČR) + KcSrazRezEHP int64 `xml:"kc_sraz_rezehp,attr,omitempty"` // ř. 87a -- sražená daň §36 odst.7 nerezident EU/EHP (MVP: 0) + KcVyplBonus int64 `xml:"kc_vyplbonus,attr,omitempty"` // ř. 89 -- úhrn vyplacených měsíčních daňových bonusů (Potvrzení ř.5+ř.13) } // DPFOVetaP contains taxpayer identification. @@ -106,14 +113,25 @@ type DPFOVetaP struct { // DPFOVetaO contains per-section tax-base inputs from §6 / §7 / §8 / §9 / §10 // and the consolidated tax base. §8/§9/§10 fields use omitempty: emitting "0" // triggers the EPO control "if ř.39 or ř.40 is filled, Příloha 2 must accompany". +// +// §6 employment income attributes (kc_prij6 / kc_prij6zahr / kc_dan_zah / kc_zd6 / +// kc_zd6p) all use omitempty so that taxpayers without employment income emit XML +// identical to the pre-§6-support output. kc_zakldan23 is always emitted because +// it represents ř.42 -- the consolidated tax base used by every downstream EPO +// formula control. type DPFOVetaO struct { - KcZd7 int64 `xml:"kc_zd7,attr"` // ř. 37 -- dílčí základ daně §7 (= ř.113 Přílohy 1) - KcZakldan8 int64 `xml:"kc_zakldan8,attr,omitempty"` // ř. 38 -- §8 capital income net base - KcZd9 int64 `xml:"kc_zd9,attr,omitempty"` // ř. 39 -- §9 rental income net base (Příloha 2 required) - KcZd10 int64 `xml:"kc_zd10,attr,omitempty"` // ř. 40 -- §10 other income net base (Příloha 2 required) - KcUhrn int64 `xml:"kc_uhrn,attr"` // ř. 41 -- úhrn ř.37+38+39+40 - KcZakldan23 int64 `xml:"kc_zakldan23,attr"` // ř. 42 -- celkový základ daně (= ř.36 + max(0,ř.41)) - KcZakldan int64 `xml:"kc_zakldan,attr"` // ř. 45 -- ZD po odpočtu ztráty + KcPrij6 int64 `xml:"kc_prij6,attr,omitempty"` // ř. 31 -- úhrn příjmů §6 (Potvrzení vzor 33 ř.2+ř.4) + KcPrij6zahr int64 `xml:"kc_prij6zahr,attr,omitempty"` // ř. 35 -- část ř.31 bez záloh dle §38h (informativní) + KcDanZah int64 `xml:"kc_dan_zah,attr,omitempty"` // ř. 33 -- daň zaplacená v zahraničí (§6 odst.13) + KcZd6 int64 `xml:"kc_zd6,attr,omitempty"` // ř. 34/36 -- dílčí ZD §6 = ř.31 - ř.33 + KcZd6p int64 `xml:"kc_zd6p,attr,omitempty"` // §38f / Příloha 3 alokace §6 portionu pro zápočet zahr. daně (MVP: 0) + KcZd7 int64 `xml:"kc_zd7,attr"` // ř. 37 -- dílčí základ daně §7 (= ř.113 Přílohy 1) + KcZakldan8 int64 `xml:"kc_zakldan8,attr,omitempty"` // ř. 38 -- §8 capital income net base + KcZd9 int64 `xml:"kc_zd9,attr,omitempty"` // ř. 39 -- §9 rental income net base (Příloha 2 required) + KcZd10 int64 `xml:"kc_zd10,attr,omitempty"` // ř. 40 -- §10 other income net base (Příloha 2 required) + KcUhrn int64 `xml:"kc_uhrn,attr"` // ř. 41 -- úhrn ř.37+38+39+40 + KcZakldan23 int64 `xml:"kc_zakldan23,attr"` // ř. 42 -- celkový základ daně (= ř.36 + max(0,ř.41)) + KcZakldan int64 `xml:"kc_zakldan,attr"` // ř. 45 -- ZD po odpočtu ztráty } // DPFOVetaS contains §15 deductions, base after deductions, rounded base and § 16 tax. @@ -159,9 +177,20 @@ type DPFOVetaA struct { // DPFOVetaB declares which attachments accompany the return. // XSD critical controls: priloha1="1" required when VetaO.kc_zd7 is filled, // priloha2="1" required when VetaO.kc_zd9 or kc_zd10 is filled. +// +// §6 employment income attachment counts: +// - potv_zam: count of "Potvrzení o zdanitelných příjmech ze závislé činnosti" +// (form 25 5460 vzor 33 -- zálohové) +// - potv_36: count of "Potvrzení o vyplacených příjmech a sražené dani" +// (form 25 5460/A vzor 12 -- srážkové), only counted when included in DAP +// - potv_dazvyh: count of standalone "Potvrzení o vyplaceném daňovém bonusu" +// forms (separate from vzor 33; MVP: 0 -- standalone bonus form upload OOS) type DPFOVetaB struct { - Priloha1 string `xml:"priloha1,attr,omitempty"` - Priloha2 string `xml:"priloha2,attr,omitempty"` + Priloha1 string `xml:"priloha1,attr,omitempty"` + Priloha2 string `xml:"priloha2,attr,omitempty"` + PotvZam int `xml:"potv_zam,attr,omitempty"` + Potv36 int `xml:"potv_36,attr,omitempty"` + PotvDazvyh int `xml:"potv_dazvyh,attr,omitempty"` } // DPFOVetaV is the §10 summary of Příloha č. 2 (other income / "ostatní příjmy"). diff --git a/internal/calc/constants.go b/internal/calc/constants.go index 2d47627..d7f2cac 100644 --- a/internal/calc/constants.go +++ b/internal/calc/constants.go @@ -70,7 +70,8 @@ var taxConstantsDB = map[int]TaxYearConstants{ MaxChildBonus: domain.NewAmount(60_300, 0), }, 2025: { - ProgressiveThreshold: domain.NewAmount(1_582_812, 0), + // 36 × průměrná mzda 46 557 Kč (nařízení vlády 282/2024 Sb.) = 1 676 052 Kč. + ProgressiveThreshold: domain.NewAmount(1_676_052, 0), BasicCredit: domain.NewAmount(30_840, 0), SpouseCredit: domain.NewAmount(24_840, 0), StudentCredit: domain.NewAmount(4_020, 0), @@ -102,6 +103,9 @@ var taxConstantsDB = map[int]TaxYearConstants{ MaxChildBonus: domain.NewAmount(60_300, 0), }, 2026: { + // TODO: update once nařízení vlády stanovující průměrnou mzdu pro rok + // 2026 vyjde — limit = 36 × průměrná mzda. Hodnota 1 582 812 Kč je + // placeholder z roku 2024 a musí být přepsána před filing season 2026. ProgressiveThreshold: domain.NewAmount(1_582_812, 0), BasicCredit: domain.NewAmount(30_840, 0), SpouseCredit: domain.NewAmount(24_840, 0), diff --git a/internal/calc/income_tax.go b/internal/calc/income_tax.go index 0c17e21..3c2c2f3 100644 --- a/internal/calc/income_tax.go +++ b/internal/calc/income_tax.go @@ -16,6 +16,28 @@ type IncomeTaxInput struct { Prepayments domain.Amount CapitalIncomeNet domain.Amount OtherIncomeNet domain.Amount + // Section6TaxBase is the §6 employment dílčí základ (ř.34/36 = ř.31 − ř.33). + // Added to the §16 progressive tax base. XSD rule for ř.42 ("if úhrn §7+§8+§10 + // is negative, use only §6") is enforced here: §7+§8+§10 contributions are + // dropped when their sum is negative, so §6 alone forms the tax base in that + // case (matches the XML generator emission). + Section6TaxBase domain.Amount + + // Section6 reconciliation values (RFC-016). Following Pokyny DPFO 2025 + // oddíl 7, ř.91 (kc_zbyvpred = "zbývá doplatit") is computed as + // ř.77 − ř.84 − ř.85 − ř.86 − ř.87 − ř.87a − úhrn záloh §7 + // where ř.84 is the employer-withheld advance and ř.87 is §36 odst. 6 + // withholding voluntarily included in DAP. We subtract those here so + // TaxDue (which feeds kc_zbyvpred) reflects the user's true balance. + // + // Section6MonthlyBonusPaid (ř.89) is the bonus the employer already paid + // out monthly. Reconciliation: TaxAfterBenefit holds the user's claim + // (negative when claimed); adding back the paid-out bonus reduces the + // claim or, if it exceeds the entitlement, increases TaxDue (the user + // must return the excess to the státu). + Section6AdvanceWithheld domain.Amount // ř.84 + Section6WithholdingCredited domain.Amount // ř.87 (only when user opted to include §36/6 in DAP) + Section6MonthlyBonusPaid domain.Amount // ř.89 (kc_vyplbonus) } // IncomeTaxResult holds all computed values from the income tax calculation. @@ -52,8 +74,17 @@ func CalculateIncomeTax(input IncomeTaxInput) IncomeTaxResult { result.UsedExpenses = input.ActualExpenses } - // Step 5: Tax base (revenue - expenses + capital income + other income). - taxBase := input.TotalRevenue - result.UsedExpenses + input.CapitalIncomeNet + input.OtherIncomeNet + // Step 5: Tax base = §6 + max(0, §7 + §8 + §10). + // §7 (business): TotalRevenue - UsedExpenses + // §8 (capital): CapitalIncomeNet + // §10 (other): OtherIncomeNet + // XSD rule for DPFO ř.42 ("Pokud je ř.41 záporný, uveďte pouze hodnotu z + // ř.36"): if úhrn §7+§8+§10 is negative, drop it and use just §6. + otherSectionsBase := input.TotalRevenue - result.UsedExpenses + input.CapitalIncomeNet + input.OtherIncomeNet + if otherSectionsBase < 0 { + otherSectionsBase = 0 + } + taxBase := input.Section6TaxBase + otherSectionsBase if taxBase < 0 { taxBase = 0 } @@ -94,8 +125,16 @@ func CalculateIncomeTax(input IncomeTaxInput) IncomeTaxResult { // Step 9: Child benefit (can go negative - it's a bonus). result.TaxAfterBenefit = result.TaxAfterCredits - input.ChildBenefit - // Step 10: Prepayments (can be negative = refund). - result.TaxDue = result.TaxAfterBenefit - input.Prepayments + // Step 10: Reconcile prepayments and §6 withholdings/advances (Pokyny + // DPFO 2025 oddíl 7, ř.91 = ř.77 − ř.84 − ř.87 − ř.87a − úhrn záloh §7). + // Section6MonthlyBonusPaid (ř.89) is added back: TaxAfterBenefit already + // captured the user's bonus claim (negative); the paid-out portion must + // be netted off (if equal → 0, if employer over-paid → positive doplatek). + result.TaxDue = result.TaxAfterBenefit - + input.Prepayments - + input.Section6AdvanceWithheld - + input.Section6WithholdingCredited + + input.Section6MonthlyBonusPaid return result } diff --git a/internal/calc/income_tax_test.go b/internal/calc/income_tax_test.go index e06aea3..ffab670 100644 --- a/internal/calc/income_tax_test.go +++ b/internal/calc/income_tax_test.go @@ -30,7 +30,7 @@ func TestCalculateIncomeTax(t *testing.T) { // UsedExpenses = 120_000_000 // TaxBase = 200_000_000 - 120_000_000 = 80_000_000 halere (800,000 CZK) // TaxBaseRounded = 80_000_000 (already divisible by 10000) - // 80_000_000 <= 158_281_200 -> only 15% + // 80_000_000 <= 167_605_200 -> only 15% // TaxAt15 = 80_000_000 * 0.15 = 12_000_000 // TotalTax = 12_000_000 // CreditBasic = 3_084_000 @@ -64,25 +64,26 @@ func TestCalculateIncomeTax(t *testing.T) { // UsedExpenses = 120_000_000 // TaxBase = 300_000_000 - 120_000_000 = 180_000_000 (1,800,000 CZK) // TaxBaseRounded = 180_000_000 - // 180_000_000 > threshold 158_281_200 - // TaxAt15 = 158_281_200 * 0.15 = 23_742_180 - // TaxAt23 = (180_000_000 - 158_281_200) * 0.23 = 21_718_800 * 0.23 = 4_995_324 - // TotalTax = 23_742_180 + 4_995_324 = 28_737_504 + // 2025 threshold = 36 × 46 557 = 1 676 052 Kč = 167_605_200 halere + // 180_000_000 > 167_605_200 -> progressive split + // TaxAt15 = 167_605_200 * 0.15 = 25_140_780 + // TaxAt23 = (180_000_000 - 167_605_200) * 0.23 = 12_394_800 * 0.23 = 2_850_804 + // TotalTax = 25_140_780 + 2_850_804 = 27_991_584 // CreditBasic = 3_084_000 - // TaxAfterCredits = 28_737_504 - 3_084_000 = 25_653_504 + // TaxAfterCredits = 27_991_584 - 3_084_000 = 24_907_584 expect: IncomeTaxResult{ FlatRateAmount: domain.NewAmount(1_200_000, 0), UsedExpenses: domain.NewAmount(1_200_000, 0), TaxBase: domain.NewAmount(1_800_000, 0), TaxBaseRounded: domain.NewAmount(1_800_000, 0), - TaxAt15: domain.Amount(23_742_180), - TaxAt23: domain.Amount(4_995_324), - TotalTax: domain.Amount(23_742_180 + 4_995_324), + TaxAt15: domain.Amount(25_140_780), + TaxAt23: domain.Amount(2_850_804), + TotalTax: domain.Amount(25_140_780 + 2_850_804), CreditBasic: domain.NewAmount(30_840, 0), TotalCredits: domain.NewAmount(30_840, 0), - TaxAfterCredits: domain.Amount(23_742_180 + 4_995_324 - 3_084_000), - TaxAfterBenefit: domain.Amount(23_742_180 + 4_995_324 - 3_084_000), - TaxDue: domain.Amount(23_742_180 + 4_995_324 - 3_084_000), + TaxAfterCredits: domain.Amount(25_140_780 + 2_850_804 - 3_084_000), + TaxAfterBenefit: domain.Amount(25_140_780 + 2_850_804 - 3_084_000), + TaxDue: domain.Amount(25_140_780 + 2_850_804 - 3_084_000), }, }, { @@ -204,7 +205,7 @@ func TestCalculateIncomeTax(t *testing.T) { Constants: c, }, // TaxBase = 50_000_000 - 10_000_000 = 40_000_000 (400,000 CZK) - // 40_000_000 < 158_281_200 -> only 15% + // 40_000_000 < 167_605_200 -> only 15% // TaxAt15 = 40_000_000 * 0.15 = 6_000_000 expect: IncomeTaxResult{ FlatRateAmount: 0, @@ -231,25 +232,25 @@ func TestCalculateIncomeTax(t *testing.T) { }, // TaxBase = 200_000_000 // TaxBaseRounded = 200_000_000 - // threshold = 158_281_200 - // TaxAt15 = 158_281_200 * 0.15 = 23_742_180 - // TaxAt23 = (200_000_000 - 158_281_200) * 0.23 = 41_718_800 * 0.23 = 9_595_324 - // TotalTax = 23_742_180 + 9_595_324 = 33_337_504 + // 2025 threshold = 36 × 46 557 = 1 676 052 Kč = 167_605_200 halere + // TaxAt15 = 167_605_200 * 0.15 = 25_140_780 + // TaxAt23 = (200_000_000 - 167_605_200) * 0.23 = 32_394_800 * 0.23 = 7_450_804 + // TotalTax = 25_140_780 + 7_450_804 = 32_591_584 // CreditBasic = 3_084_000 - // TaxAfterCredits = 33_337_504 - 3_084_000 = 30_253_504 + // TaxAfterCredits = 32_591_584 - 3_084_000 = 29_507_584 expect: IncomeTaxResult{ FlatRateAmount: 0, UsedExpenses: 0, TaxBase: domain.NewAmount(2_000_000, 0), TaxBaseRounded: domain.NewAmount(2_000_000, 0), - TaxAt15: domain.Amount(23_742_180), - TaxAt23: domain.Amount(9_595_324), - TotalTax: domain.Amount(33_337_504), + TaxAt15: domain.Amount(25_140_780), + TaxAt23: domain.Amount(7_450_804), + TotalTax: domain.Amount(32_591_584), CreditBasic: domain.NewAmount(30_840, 0), TotalCredits: domain.NewAmount(30_840, 0), - TaxAfterCredits: domain.Amount(33_337_504 - 3_084_000), - TaxAfterBenefit: domain.Amount(33_337_504 - 3_084_000), - TaxDue: domain.Amount(33_337_504 - 3_084_000), + TaxAfterCredits: domain.Amount(32_591_584 - 3_084_000), + TaxAfterBenefit: domain.Amount(32_591_584 - 3_084_000), + TaxDue: domain.Amount(32_591_584 - 3_084_000), }, }, { @@ -367,7 +368,7 @@ func TestCalculateIncomeTax(t *testing.T) { // TaxBase = 150_000_000 - 90_000_000 + 1_000_000 + 500_000 = 61_500_000 (615,000 CZK) // After deductions: 61_500_000 - 2_400_000 = 59_100_000 (591,000 CZK) // TaxBaseRounded = 59_100_000 (already divisible by 10000) - // 59_100_000 < 158_281_200 -> only 15% + // 59_100_000 < 167_605_200 -> only 15% // TaxAt15 = 59_100_000 * 0.15 = 8_865_000 // CreditBasic = 3_084_000 // TotalCredits = 3_084_000 + 2_484_000 + 0 + 0 = 5_568_000 @@ -417,3 +418,81 @@ func assertAmount(t *testing.T, field string, want, got domain.Amount) { t.Errorf("%s: want %d (%s CZK), got %d (%s CZK)", field, want, want, got, got) } } + +// TestTaxDue_SubtractsSection6Advance verifies ř.91 (kc_zbyvpred = TaxDue) +// reflects employer-withheld §6 advances. With 600 000 Kč gross §6, full +// CreditBasic eats most of the tax; the 60 000 Kč withheld advance pulls +// TaxDue strongly negative (refund owed back to user). +func TestTaxDue_SubtractsSection6Advance(t *testing.T) { + c := constants2025() + input := IncomeTaxInput{ + Section6TaxBase: domain.NewAmount(600_000, 0), + Section6AdvanceWithheld: domain.NewAmount(60_000, 0), + Constants: c, + } + result := CalculateIncomeTax(input) + // Tax = 600 000 × 0.15 = 90 000; minus CreditBasic 30 840 = 59 160 Kč + // after credits. ř.91 = 59 160 − 0 (prepayments) − 60 000 (ř.84) = −840 Kč. + wantDue := domain.NewAmount(59_160, 0) - domain.NewAmount(60_000, 0) + if result.TaxDue != wantDue { + t.Errorf("TaxDue = %d, want %d (%s CZK)", result.TaxDue, wantDue, wantDue) + } +} + +// TestTaxDue_SubtractsSection6Withholding verifies ř.91 reflects the +// §36/6 withholding amount (ř.87) when the user opted to include a +// vzor-12 Potvrzení in DAP. +func TestTaxDue_SubtractsSection6Withholding(t *testing.T) { + c := constants2025() + input := IncomeTaxInput{ + Section6TaxBase: domain.NewAmount(200_000, 0), + Section6WithholdingCredited: domain.NewAmount(15_000, 0), + Constants: c, + } + result := CalculateIncomeTax(input) + // Tax = 200 000 × 0.15 = 30 000; minus CreditBasic 30 840 → 0. + // TaxDue = 0 − 0 − 0 − 15 000 = −15 000 Kč (refund). + wantDue := -domain.NewAmount(15_000, 0) + if result.TaxDue != wantDue { + t.Errorf("TaxDue = %d, want %d (%s CZK)", result.TaxDue, wantDue, wantDue) + } +} + +// TestTaxDue_BonusOverpayReturnsToState verifies that when the employer +// already paid out more monthly bonus than the user is entitled to (ř.89 > +// ř.72), TaxDue increases — the user must return the excess to státu. +func TestTaxDue_BonusOverpayReturnsToState(t *testing.T) { + c := constants2025() + input := IncomeTaxInput{ + ChildBenefit: domain.NewAmount(15_204, 0), // 1 dítě roční nárok + Section6MonthlyBonusPaid: domain.NewAmount(18_000, 0), // employer paid more than entitled + Constants: c, + } + result := CalculateIncomeTax(input) + // TaxAfterCredits = 0 (no tax base). TaxAfterBenefit = 0 − 15 204 = −15 204. + // TaxDue = −15 204 − 0 − 0 − 0 + 18 000 = 2 796 Kč owed back to státu. + wantDue := domain.NewAmount(18_000, 0) - domain.NewAmount(15_204, 0) + if result.TaxDue != wantDue { + t.Errorf("TaxDue = %d, want %d (%s CZK) — bonus over-pay must return to státu", + result.TaxDue, wantDue, wantDue) + } +} + +// TestTaxDue_BonusUnderpayClaimsRest verifies that when the employer paid +// out less monthly bonus than the user is entitled to, TaxDue decreases — +// the user can still claim the remaining bonus on DAP. +func TestTaxDue_BonusUnderpayClaimsRest(t *testing.T) { + c := constants2025() + input := IncomeTaxInput{ + ChildBenefit: domain.NewAmount(15_204, 0), + Section6MonthlyBonusPaid: domain.NewAmount(10_000, 0), + Constants: c, + } + result := CalculateIncomeTax(input) + // TaxAfterBenefit = −15 204. TaxDue = −15 204 + 10 000 = −5 204 (still + // owed to user, but less than full claim because employer already paid 10k). + wantDue := domain.NewAmount(10_000, 0) - domain.NewAmount(15_204, 0) + if result.TaxDue != wantDue { + t.Errorf("TaxDue = %d, want %d (%s CZK)", result.TaxDue, wantDue, wantDue) + } +} diff --git a/internal/database/migrations/027_employment_income.sql b/internal/database/migrations/027_employment_income.sql new file mode 100644 index 0000000..72892b0 --- /dev/null +++ b/internal/database/migrations/027_employment_income.sql @@ -0,0 +1,75 @@ +-- +goose Up + +-- Naskenovaná Potvrzení (PDF/JPG/PNG/WEBP) +CREATE TABLE employment_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + year INTEGER NOT NULL, + document_kind TEXT NOT NULL DEFAULT 'advance', -- advance | withholding | bonus + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + storage_path TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + extraction_status TEXT NOT NULL DEFAULT 'pending', -- pending | extracted | failed + extraction_error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX idx_employment_docs_year ON employment_documents(year); + +-- Vyextrahovaný / ručně zadaný certifikát (1 plátce, 1 typ Potvrzení, 1 období) +CREATE TABLE employment_income_certificates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + year INTEGER NOT NULL, + document_id INTEGER REFERENCES employment_documents(id) ON DELETE SET NULL, + certificate_type TEXT NOT NULL DEFAULT 'advance', -- advance | withholding + employer_name TEXT NOT NULL DEFAULT '', + employer_ico TEXT NOT NULL DEFAULT '', + employer_address TEXT NOT NULL DEFAULT '', + contract_type TEXT NOT NULL DEFAULT 'dpc', -- dpc | dpp | hpp | other + period_from TEXT NOT NULL, + period_to TEXT NOT NULL, + -- Z Potvrzení 25 5460 vzor 33 (advance) + gross_income INTEGER NOT NULL DEFAULT 0, -- ř.2 + ř.4 Potvrzení -> ř.31 DAP + income_without_advance INTEGER NOT NULL DEFAULT 0, -- část bez záloh dle §38h (zahr. zastup. úřady, zahr. zaměstnavatelé) -> ř.35 DAP + foreign_tax_paid INTEGER NOT NULL DEFAULT 0, -- §6 odst.13 daň zaplacená v zahraničí -> ř.33 DAP + advance_tax_withheld INTEGER NOT NULL DEFAULT 0, -- ř.8 Potvrzení -> ř.84 DAP + annual_settlement_refund INTEGER NOT NULL DEFAULT 0, -- vrácený přeplatek z RZ (snižuje ř.84) + monthly_bonus_paid INTEGER NOT NULL DEFAULT 0, -- ř.5 + ř.13 Potvrzení -> ř.89 DAP (kc_vyplbonus) + -- Z Potvrzení 25 5460/A vzor 12 (withholding) + withheld_final_tax INTEGER NOT NULL DEFAULT 0, -- §36/6/7 sražená daň -> ř.87 DAP + include_withholding_in_dap INTEGER NOT NULL DEFAULT 0, -- 1 = zahrnout do ř.31 a ř.87 + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'draft', -- draft | confirmed + deleted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (year, employer_ico, certificate_type, period_from, period_to) + ON CONFLICT REPLACE +); +CREATE INDEX idx_employment_certs_year ON employment_income_certificates(year); + +-- §6 agregáty na income_tax_returns +ALTER TABLE income_tax_returns ADD COLUMN section6_gross_income INTEGER NOT NULL DEFAULT 0; -- ř.31 +ALTER TABLE income_tax_returns ADD COLUMN section6_income_without_advance INTEGER NOT NULL DEFAULT 0; -- ř.35 +ALTER TABLE income_tax_returns ADD COLUMN section6_foreign_tax INTEGER NOT NULL DEFAULT 0; -- ř.33 +ALTER TABLE income_tax_returns ADD COLUMN section6_tax_base INTEGER NOT NULL DEFAULT 0; -- ř.34/36 +ALTER TABLE income_tax_returns ADD COLUMN section6_advance_withheld INTEGER NOT NULL DEFAULT 0; -- ř.84 +ALTER TABLE income_tax_returns ADD COLUMN section6_withholding_credited INTEGER NOT NULL DEFAULT 0; -- ř.87 +ALTER TABLE income_tax_returns ADD COLUMN section6_monthly_bonus_paid INTEGER NOT NULL DEFAULT 0; -- ř.89 (kc_vyplbonus) +ALTER TABLE income_tax_returns ADD COLUMN section6_certs_advance INTEGER NOT NULL DEFAULT 0; -- potv_zam count +ALTER TABLE income_tax_returns ADD COLUMN section6_certs_withholding INTEGER NOT NULL DEFAULT 0; -- potv_36 count +ALTER TABLE income_tax_returns ADD COLUMN section6_certs_bonus INTEGER NOT NULL DEFAULT 0; -- potv_dazvyh count + +-- +goose Down +ALTER TABLE income_tax_returns DROP COLUMN section6_gross_income; +ALTER TABLE income_tax_returns DROP COLUMN section6_income_without_advance; +ALTER TABLE income_tax_returns DROP COLUMN section6_foreign_tax; +ALTER TABLE income_tax_returns DROP COLUMN section6_tax_base; +ALTER TABLE income_tax_returns DROP COLUMN section6_advance_withheld; +ALTER TABLE income_tax_returns DROP COLUMN section6_withholding_credited; +ALTER TABLE income_tax_returns DROP COLUMN section6_monthly_bonus_paid; +ALTER TABLE income_tax_returns DROP COLUMN section6_certs_advance; +ALTER TABLE income_tax_returns DROP COLUMN section6_certs_withholding; +ALTER TABLE income_tax_returns DROP COLUMN section6_certs_bonus; +DROP TABLE IF EXISTS employment_income_certificates; +DROP TABLE IF EXISTS employment_documents; diff --git a/internal/database/migrations/028_income_tax_warnings.sql b/internal/database/migrations/028_income_tax_warnings.sql new file mode 100644 index 0000000..063b8f7 --- /dev/null +++ b/internal/database/migrations/028_income_tax_warnings.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- Stored as a comma-separated list of short warning tokens +-- (e.g. "progressive_rate_review,withholding_partial_include"). Comma- +-- separated chosen over JSON for simplicity — values are short fixed-set +-- identifiers without nested structure. Empty string ("") = no warnings. +ALTER TABLE income_tax_returns ADD COLUMN warnings TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE income_tax_returns DROP COLUMN warnings; diff --git a/internal/domain/annual_tax.go b/internal/domain/annual_tax.go index 8ce88ce..cf0f129 100644 --- a/internal/domain/annual_tax.go +++ b/internal/domain/annual_tax.go @@ -2,6 +2,22 @@ package domain import "time" +// IncomeTaxReturn warning tokens raised during Recalculate. These are +// advisory only — they do not block XML generation or filing, but the UI +// surfaces them so the user can verify against the legal ground truth. +const ( + // WarningProgressiveRateReview signals the consolidated tax base + // (§6 + §7 + §8 + §10) crossed 36× průměrná mzda for the year, putting + // the upper portion into the §16 odst. 1 ZDP 23% bracket. The user must + // verify the split is correct (existing CalculateIncomeTax handles it). + WarningProgressiveRateReview = "progressive_rate_review" + + // WarningWithholdingPartialInclude is reserved for §38g odst. 6 ZDP + // enforcement (out of scope for current MVP — left here so future + // implementations can adopt the same identifier). + WarningWithholdingPartialInclude = "withholding_partial_include" +) + // IncomeTaxReturn represents DPFO (danove priznani fyzickych osob). type IncomeTaxReturn struct { ID int64 @@ -59,7 +75,25 @@ type IncomeTaxReturn struct { OtherIncomeExempt Amount OtherIncomeNet Amount - XMLData []byte + // §6 employment income aggregates (DPC/DPP/HPP) + Section6GrossIncome Amount // ř.31 + Section6IncomeWithoutAdvance Amount // ř.35 (informativní; §38h) + Section6ForeignTax Amount // ř.33 + Section6TaxBase Amount // ř.34/36 = ř.31 - ř.33 + Section6AdvanceWithheld Amount // ř.84 (po vrácení přeplatku z RZ) + Section6WithholdingCredited Amount // ř.87 (jen pokud uživatel zahrnul §36/6 do DAP) + Section6MonthlyBonusPaid Amount // ř.89 kc_vyplbonus (vyplacené zaměstnavatelem; NESLEVÍ z ChildBenefit) + Section6CertsAdvance int // count -> potv_zam + Section6CertsWithholding int // count -> potv_36 + Section6CertsBonus int // count -> potv_dazvyh + + XMLData []byte + + // Warnings carries non-blocking advisory tokens raised during Recalculate + // (e.g. progressive 23% rate review for §16 odst. 1 ZDP). Persisted as a + // comma-separated string in the warnings column. Empty slice = no warnings. + Warnings []string + Status string // draft, ready, filed FiledAt *time.Time CreatedAt time.Time diff --git a/internal/domain/contact.go b/internal/domain/contact.go index 0c7a60f..6a7cadf 100644 --- a/internal/domain/contact.go +++ b/internal/domain/contact.go @@ -1,6 +1,10 @@ package domain -import "time" +import ( + "fmt" + "strings" + "time" +) // Contact type constants. const ( @@ -83,3 +87,60 @@ func (c *Contact) IsEUPartner() bool { func (c *Contact) HasCZDIC() bool { return c.DICCountryCode() == "CZ" } + +// ValidateICO validates a Czech IČO (identification number) using the ARES +// modulo-11 checksum algorithm. +// +// Algorithm (per ARES specification): +// 1. Trim whitespace, left-pad with zeros to 8 digits if shorter. +// 2. All 8 characters must be digits. +// 3. Multiply the first 7 digits by weights [8, 7, 6, 5, 4, 3, 2] and sum. +// 4. Compute remainder = sum mod 11. +// 5. Expected check digit: +// - remainder == 0 → 1 +// - remainder == 1 → 0 +// - otherwise → 11 − remainder +// 6. Compare expected check digit to the 8th digit of the input. +// +// Returns nil for a valid IČO, otherwise an error wrapping ErrInvalidInput. +func ValidateICO(ico string) error { + ico = strings.TrimSpace(ico) + if ico == "" { + return fmt.Errorf("IČO is empty: %w", ErrInvalidInput) + } + // Left-pad with zeros to 8 digits (ARES allows 6-, 7-, 8-digit forms). + for len(ico) < 8 { + ico = "0" + ico + } + if len(ico) != 8 { + return fmt.Errorf("IČO %q must be 8 digits: %w", ico, ErrInvalidInput) + } + for _, r := range ico { + if r < '0' || r > '9' { + return fmt.Errorf("IČO %q must contain only digits: %w", ico, ErrInvalidInput) + } + } + + weights := [7]int{8, 7, 6, 5, 4, 3, 2} + sum := 0 + for i := 0; i < 7; i++ { + sum += int(ico[i]-'0') * weights[i] + } + remainder := sum % 11 + + var expected int + switch remainder { + case 0: + expected = 1 + case 1: + expected = 0 + default: + expected = 11 - remainder + } + + actual := int(ico[7] - '0') + if actual != expected { + return fmt.Errorf("IČO %q has invalid checksum (expected %d, got %d): %w", ico, expected, actual, ErrInvalidInput) + } + return nil +} diff --git a/internal/domain/contact_test.go b/internal/domain/contact_test.go index 2cdae44..3c2c163 100644 --- a/internal/domain/contact_test.go +++ b/internal/domain/contact_test.go @@ -1,6 +1,9 @@ package domain -import "testing" +import ( + "errors" + "testing" +) func TestContact_DICCountryCode(t *testing.T) { tests := []struct { @@ -72,3 +75,54 @@ func TestContact_HasCZDIC(t *testing.T) { }) } } + +// TestValidateICO_ModuloEleven exercises the modulo-11 checksum, including +// padding short inputs, invalid characters, and check-digit edge cases. +func TestValidateICO_ModuloEleven(t *testing.T) { + validCases := []struct { + name, ico string + }{ + // 27082440: 2*8+7*7+0*6+8*5+2*4+4*3+4*2 = 133, 133 mod 11 = 1 → expected 0, actual 0 ✓ + {"valid ARES 27082440", "27082440"}, + // 25596641 (Microsoft s.r.o. CZ): 2*8+5*7+5*6+9*5+6*4+6*3+6*2 = 16+35+30+45+24+18+12 = 180, + // 180 mod 11 = 180-176 = 4 → expected 11-4 = 7. Skip. + // Use 26168685: 2*8+6*7+1*6+6*5+8*4+6*3+8*2 = 16+42+6+30+32+18+16 = 160, + // 160 mod 11 = 160-154 = 6 → expected 11-6 = 5, actual 5 ✓. + {"valid 26168685", "26168685"}, + // Padded short input: "7082440" → "07082440": 0*8+7*7+0*6+8*5+2*4+4*3+4*2 = 0+49+0+40+8+12+8 = 117, + // 117 mod 11 = 117-110 = 7 → expected 11-7 = 4. Actual 0. INVALID. So pick another. + // 6 digits: "082440" pads to "00082440": 0+0+0+8*5+2*4+4*3+4*2 = 40+8+12+8 = 68, + // 68 mod 11 = 68-66 = 2 → expected 11-2 = 9. actual 0. INVALID. + // Use "7082440" only valid if we recompute; skip padding-with-real-data and just test a known check. + // Whitespace trimming. + {"whitespace trimmed", " 27082440 "}, + } + for _, tc := range validCases { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateICO(tc.ico); err != nil { + t.Errorf("ValidateICO(%q) returned error: %v", tc.ico, err) + } + }) + } + + invalidCases := []struct { + name, ico string + }{ + {"invalid checksum 12345678", "12345678"}, + {"empty", ""}, + {"non-digit", "1234567A"}, + {"too long", "123456789"}, + } + for _, tc := range invalidCases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateICO(tc.ico) + if err == nil { + t.Errorf("ValidateICO(%q) expected error, got nil", tc.ico) + return + } + if !errors.Is(err, ErrInvalidInput) { + t.Errorf("ValidateICO(%q) error %v should wrap ErrInvalidInput", tc.ico, err) + } + }) + } +} diff --git a/internal/domain/employment_income.go b/internal/domain/employment_income.go new file mode 100644 index 0000000..ce97861 --- /dev/null +++ b/internal/domain/employment_income.go @@ -0,0 +1,87 @@ +package domain + +import "time" + +// EmploymentDocumentKind classifies an uploaded employment document. +type EmploymentDocumentKind string + +const ( + // EmploymentDocAdvance corresponds to "Potvrzení o zdanitelných příjmech ze + // závislé činnosti" form 25 5460 vzor 33 (zálohové). + EmploymentDocAdvance EmploymentDocumentKind = "advance" + // EmploymentDocWithholding corresponds to form 25 5460/A vzor 12 (srážková daň). + EmploymentDocWithholding EmploymentDocumentKind = "withholding" + // EmploymentDocBonus corresponds to "Potvrzení o vyplaceném daňovém bonusu". + // Reserved for future use; no upload UI in MVP. + EmploymentDocBonus EmploymentDocumentKind = "bonus" +) + +// CertificateType classifies the parsed Potvrzení certificate. +type CertificateType string + +const ( + // CertificateAdvance maps to vzor 33 (zálohové daně). + CertificateAdvance CertificateType = "advance" + // CertificateWithholding maps to vzor 12 (srážková daň §36/6/7). + CertificateWithholding CertificateType = "withholding" +) + +// ContractType describes the underlying employment contract. +type ContractType string + +const ( + // ContractDPC = Dohoda o pracovní činnosti. + ContractDPC ContractType = "dpc" + // ContractDPP = Dohoda o provedení práce. + ContractDPP ContractType = "dpp" + // ContractHPP = Hlavní pracovní poměr. + ContractHPP ContractType = "hpp" + // ContractOther covers any other contract form. + ContractOther ContractType = "other" +) + +// EmploymentDocument represents an uploaded Potvrzení (PDF/JPG/PNG/WEBP) for a +// given year. Extraction status follows the same pending/extracted/failed +// lifecycle as InvestmentDocument. +type EmploymentDocument struct { + ID int64 + Year int + Kind EmploymentDocumentKind + Filename string + ContentType string + StoragePath string + Size int64 + ExtractionStatus string + ExtractionError string + CreatedAt time.Time + UpdatedAt time.Time +} + +// EmploymentCertificate is one parsed/manually entered Potvrzení o zdanitelných +// příjmech ze závislé činnosti. A single EmploymentCertificate maps to a single +// (employer, certificate_type, period) tuple per year. +type EmploymentCertificate struct { + ID int64 + Year int + DocumentID *int64 + CertificateType CertificateType + EmployerName string + EmployerICO string + EmployerAddress string + ContractType ContractType + PeriodFrom time.Time + PeriodTo time.Time + GrossIncome Amount // ř.2 + ř.4 Potvrzení -> ř.31 DAP + IncomeWithoutAdvance Amount // bez záloh dle §38h -> ř.35 DAP + ForeignTaxPaid Amount // §6 odst.13 -> ř.33 DAP + AdvanceTaxWithheld Amount // ř.8 Potvrzení -> ř.84 DAP + AnnualSettlementRefund Amount // vrácený přeplatek z RZ + MonthlyBonusPaid Amount // ř.5 + ř.13 Potvrzení -> ř.89 DAP + WithheldFinalTax Amount + IncludeWithholdingInDAP bool + Notes string + Status string + DeletedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/handler/audit_log_handler.go b/internal/handler/audit_log_handler.go index 4cd636b..87492c7 100644 --- a/internal/handler/audit_log_handler.go +++ b/internal/handler/audit_log_handler.go @@ -54,6 +54,7 @@ var validEntityTypes = map[string]bool{ "tax_personal_credits": true, "tax_deduction": true, "document": true, "tax_deduction_document": true, "investment_document": true, "capital_income": true, "security_transaction": true, + "employment_document": true, "employment_certificate": true, } // validActions is the allowlist of actions accepted for filtering. diff --git a/internal/handler/employment_handler.go b/internal/handler/employment_handler.go new file mode 100644 index 0000000..64fa888 --- /dev/null +++ b/internal/handler/employment_handler.go @@ -0,0 +1,477 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/zajca/zfaktury/internal/domain" +) + +// EmploymentHandler handles HTTP requests for §6 employment income management +// (Potvrzení o zdanitelných příjmech ze závislé činnosti). +type EmploymentHandler struct { + svc employmentService +} + +// employmentService is the local interface against which this handler depends. +// The concrete implementation is *service.EmploymentCertificateService; using +// an interface keeps the handler buildable independently of the service file. +type employmentService interface { + UploadDocument(ctx context.Context, year int, kind, filename, contentType string, content io.Reader) (*domain.EmploymentDocument, error) + ExtractDocument(ctx context.Context, docID int64) (*domain.EmploymentCertificate, error) + ListDocumentsByYear(ctx context.Context, year int) ([]*domain.EmploymentDocument, error) + DeleteDocument(ctx context.Context, id int64) error + Create(ctx context.Context, cert *domain.EmploymentCertificate) error + Update(ctx context.Context, cert *domain.EmploymentCertificate) error + Confirm(ctx context.Context, certID int64) error + Get(ctx context.Context, certID int64) (*domain.EmploymentCertificate, error) + ListByYear(ctx context.Context, year int) ([]*domain.EmploymentCertificate, error) + Delete(ctx context.Context, certID int64) error +} + +// NewEmploymentHandler returns a handler wired to the employment certificate +// service. +func NewEmploymentHandler(svc employmentService) *EmploymentHandler { + return &EmploymentHandler{svc: svc} +} + +// maxEmploymentUploadBytes caps the multipart body for /documents uploads at +// 10 MB per RFC-016. +const maxEmploymentUploadBytes = 10 << 20 + +// Routes returns a chi router with all employment endpoints registered. +func (h *EmploymentHandler) Routes() chi.Router { + r := chi.NewRouter() + + // Documents. + r.Post("/documents", h.UploadDocument) + r.Get("/documents", h.ListDocuments) + r.Post("/documents/{id}/extract", h.ExtractDocument) + r.Delete("/documents/{id}", h.DeleteDocument) + + // Certificates. + r.Get("/certificates", h.ListCertificates) + r.Post("/certificates", h.CreateCertificate) + r.Get("/certificates/{id}", h.GetCertificate) + r.Put("/certificates/{id}", h.UpdateCertificate) + r.Post("/certificates/{id}/confirm", h.ConfirmCertificate) + r.Delete("/certificates/{id}", h.DeleteCertificate) + + return r +} + +// --- DTOs --- + +// employmentDocumentResponse is the JSON response for an employment document. +type employmentDocumentResponse struct { + ID int64 `json:"id"` + Year int `json:"year"` + Kind string `json:"kind"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + ExtractionStatus string `json:"extraction_status"` + ExtractionError string `json:"extraction_error,omitempty"` + CreatedAt string `json:"created_at"` +} + +// employmentCertificateRequest is the JSON request body for create/update of a +// certificate. Money fields are accepted in CZK (whole CZK + decimals) and +// converted to halere via amountFromCZK on entry. +type employmentCertificateRequest struct { + Year int `json:"year"` + DocumentID *int64 `json:"document_id,omitempty"` + CertificateType string `json:"certificate_type"` + EmployerName string `json:"employer_name"` + EmployerICO string `json:"employer_ico"` + EmployerAddress string `json:"employer_address,omitempty"` + ContractType string `json:"contract_type"` + PeriodFrom string `json:"period_from"` + PeriodTo string `json:"period_to"` + GrossIncomeCZK float64 `json:"gross_income_czk"` + IncomeWithoutAdvanceCZK float64 `json:"income_without_advance_czk"` + ForeignTaxPaidCZK float64 `json:"foreign_tax_paid_czk"` + AdvanceTaxWithheldCZK float64 `json:"advance_tax_withheld_czk"` + AnnualSettlementRefundCZK float64 `json:"annual_settlement_refund_czk"` + MonthlyBonusPaidCZK float64 `json:"monthly_bonus_paid_czk"` + WithheldFinalTaxCZK float64 `json:"withheld_final_tax_czk"` + IncludeWithholdingInDAP bool `json:"include_withholding_in_dap"` + Notes string `json:"notes,omitempty"` +} + +// employmentCertificateResponse is the JSON response for an employment +// certificate. +type employmentCertificateResponse struct { + ID int64 `json:"id"` + Year int `json:"year"` + DocumentID *int64 `json:"document_id,omitempty"` + CertificateType string `json:"certificate_type"` + EmployerName string `json:"employer_name"` + EmployerICO string `json:"employer_ico"` + EmployerAddress string `json:"employer_address,omitempty"` + ContractType string `json:"contract_type"` + PeriodFrom string `json:"period_from"` + PeriodTo string `json:"period_to"` + GrossIncomeCZK float64 `json:"gross_income_czk"` + IncomeWithoutAdvanceCZK float64 `json:"income_without_advance_czk"` + ForeignTaxPaidCZK float64 `json:"foreign_tax_paid_czk"` + AdvanceTaxWithheldCZK float64 `json:"advance_tax_withheld_czk"` + AnnualSettlementRefundCZK float64 `json:"annual_settlement_refund_czk"` + MonthlyBonusPaidCZK float64 `json:"monthly_bonus_paid_czk"` + WithheldFinalTaxCZK float64 `json:"withheld_final_tax_czk"` + IncludeWithholdingInDAP bool `json:"include_withholding_in_dap"` + Notes string `json:"notes,omitempty"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// --- Conversion helpers --- + +// amountFromCZK converts a CZK float (e.g. 12345.67) into halere as +// domain.Amount. Result is rounded half-away-from-zero. +func amountFromCZK(czk float64) domain.Amount { + if czk >= 0 { + return domain.Amount(czk*100 + 0.5) + } + return domain.Amount(czk*100 - 0.5) +} + +// amountToCZK converts halere to CZK as a float. +func amountToCZK(a domain.Amount) float64 { + return float64(a) / 100.0 +} + +// employmentDocFromDomain maps a domain.EmploymentDocument to its DTO. +func employmentDocFromDomain(doc *domain.EmploymentDocument) employmentDocumentResponse { + return employmentDocumentResponse{ + ID: doc.ID, + Year: doc.Year, + Kind: string(doc.Kind), + Filename: doc.Filename, + ContentType: doc.ContentType, + Size: doc.Size, + ExtractionStatus: doc.ExtractionStatus, + ExtractionError: doc.ExtractionError, + CreatedAt: doc.CreatedAt.Format(time.RFC3339), + } +} + +// employmentCertFromDomain maps a domain.EmploymentCertificate to its DTO. +func employmentCertFromDomain(c *domain.EmploymentCertificate) employmentCertificateResponse { + return employmentCertificateResponse{ + ID: c.ID, + Year: c.Year, + DocumentID: c.DocumentID, + CertificateType: string(c.CertificateType), + EmployerName: c.EmployerName, + EmployerICO: c.EmployerICO, + EmployerAddress: c.EmployerAddress, + ContractType: string(c.ContractType), + PeriodFrom: c.PeriodFrom.Format("2006-01-02"), + PeriodTo: c.PeriodTo.Format("2006-01-02"), + GrossIncomeCZK: amountToCZK(c.GrossIncome), + IncomeWithoutAdvanceCZK: amountToCZK(c.IncomeWithoutAdvance), + ForeignTaxPaidCZK: amountToCZK(c.ForeignTaxPaid), + AdvanceTaxWithheldCZK: amountToCZK(c.AdvanceTaxWithheld), + AnnualSettlementRefundCZK: amountToCZK(c.AnnualSettlementRefund), + MonthlyBonusPaidCZK: amountToCZK(c.MonthlyBonusPaid), + WithheldFinalTaxCZK: amountToCZK(c.WithheldFinalTax), + IncludeWithholdingInDAP: c.IncludeWithholdingInDAP, + Notes: c.Notes, + Status: c.Status, + CreatedAt: c.CreatedAt.Format(time.RFC3339), + UpdatedAt: c.UpdatedAt.Format(time.RFC3339), + } +} + +// toDomain maps a request DTO to a domain.EmploymentCertificate. The id field +// is left to the caller to set on update paths. +func (r *employmentCertificateRequest) toDomain() (*domain.EmploymentCertificate, error) { + periodFrom, err := time.Parse("2006-01-02", r.PeriodFrom) + if err != nil { + return nil, errors.New("invalid period_from format, expected YYYY-MM-DD") + } + periodTo, err := time.Parse("2006-01-02", r.PeriodTo) + if err != nil { + return nil, errors.New("invalid period_to format, expected YYYY-MM-DD") + } + + cert := &domain.EmploymentCertificate{ + Year: r.Year, + DocumentID: r.DocumentID, + CertificateType: domain.CertificateType(r.CertificateType), + EmployerName: r.EmployerName, + EmployerICO: r.EmployerICO, + EmployerAddress: r.EmployerAddress, + ContractType: domain.ContractType(r.ContractType), + PeriodFrom: periodFrom, + PeriodTo: periodTo, + GrossIncome: amountFromCZK(r.GrossIncomeCZK), + IncomeWithoutAdvance: amountFromCZK(r.IncomeWithoutAdvanceCZK), + ForeignTaxPaid: amountFromCZK(r.ForeignTaxPaidCZK), + AdvanceTaxWithheld: amountFromCZK(r.AdvanceTaxWithheldCZK), + AnnualSettlementRefund: amountFromCZK(r.AnnualSettlementRefundCZK), + MonthlyBonusPaid: amountFromCZK(r.MonthlyBonusPaidCZK), + WithheldFinalTax: amountFromCZK(r.WithheldFinalTaxCZK), + IncludeWithholdingInDAP: r.IncludeWithholdingInDAP, + Notes: r.Notes, + } + return cert, nil +} + +// --- Document handlers --- + +// UploadDocument handles POST /documents (multipart form, max 10 MB). +// Required form fields: file. Required query parameters: year. Optional: kind +// (defaults to "advance"). +func (h *EmploymentHandler) UploadDocument(w http.ResponseWriter, r *http.Request) { + // Hard cap on the request body before parsing the form to prevent memory + // exhaustion. + r.Body = http.MaxBytesReader(w, r.Body, maxEmploymentUploadBytes) + if err := r.ParseMultipartForm(maxEmploymentUploadBytes); err != nil { + respondError(w, http.StatusBadRequest, "invalid multipart form or file too large") + return + } + + yearStr := r.URL.Query().Get("year") + if yearStr == "" { + yearStr = r.FormValue("year") + } + if yearStr == "" { + respondError(w, http.StatusBadRequest, "year is required") + return + } + year, err := strconv.Atoi(yearStr) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid year parameter") + return + } + + kind := r.URL.Query().Get("kind") + if kind == "" { + kind = r.FormValue("kind") + } + if kind == "" { + kind = string(domain.EmploymentDocAdvance) + } + + file, header, err := r.FormFile("file") + if err != nil { + respondError(w, http.StatusBadRequest, "file is required") + return + } + defer func() { _ = file.Close() }() + + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + doc, err := h.svc.UploadDocument(r.Context(), year, kind, header.Filename, contentType, file) + if err != nil { + slog.Error("uploading employment document", "error", err) + mapDomainError(w, err) + return + } + + respondJSON(w, http.StatusCreated, employmentDocFromDomain(doc)) +} + +// ListDocuments handles GET /documents?year=. +func (h *EmploymentHandler) ListDocuments(w http.ResponseWriter, r *http.Request) { + year, ok := readYearQuery(w, r) + if !ok { + return + } + + docs, err := h.svc.ListDocumentsByYear(r.Context(), year) + if err != nil { + slog.Error("listing employment documents", "error", err) + respondError(w, http.StatusInternalServerError, "failed to list documents") + return + } + + items := make([]employmentDocumentResponse, 0, len(docs)) + for _, d := range docs { + items = append(items, employmentDocFromDomain(d)) + } + respondJSON(w, http.StatusOK, items) +} + +// DeleteDocument handles DELETE /documents/{id}. +func (h *EmploymentHandler) DeleteDocument(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid document ID") + return + } + if err := h.svc.DeleteDocument(r.Context(), id); err != nil { + slog.Error("deleting employment document", "error", err, "id", id) + mapDomainError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ExtractDocument handles POST /documents/{id}/extract. Returns a draft +// EmploymentCertificate parsed by the OCR provider. +func (h *EmploymentHandler) ExtractDocument(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid document ID") + return + } + cert, err := h.svc.ExtractDocument(r.Context(), id) + if err != nil { + slog.Error("extracting employment document", "error", err, "id", id) + mapDomainError(w, err) + return + } + respondJSON(w, http.StatusOK, employmentCertFromDomain(cert)) +} + +// --- Certificate handlers --- + +// ListCertificates handles GET /certificates?year=. +func (h *EmploymentHandler) ListCertificates(w http.ResponseWriter, r *http.Request) { + year, ok := readYearQuery(w, r) + if !ok { + return + } + certs, err := h.svc.ListByYear(r.Context(), year) + if err != nil { + slog.Error("listing employment certificates", "error", err) + respondError(w, http.StatusInternalServerError, "failed to list certificates") + return + } + items := make([]employmentCertificateResponse, 0, len(certs)) + for _, c := range certs { + items = append(items, employmentCertFromDomain(c)) + } + respondJSON(w, http.StatusOK, items) +} + +// GetCertificate handles GET /certificates/{id}. +func (h *EmploymentHandler) GetCertificate(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid certificate ID") + return + } + cert, err := h.svc.Get(r.Context(), id) + if err != nil { + slog.Error("fetching employment certificate", "error", err, "id", id) + mapDomainError(w, err) + return + } + respondJSON(w, http.StatusOK, employmentCertFromDomain(cert)) +} + +// CreateCertificate handles POST /certificates. +func (h *EmploymentHandler) CreateCertificate(w http.ResponseWriter, r *http.Request) { + var req employmentCertificateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + cert, err := req.toDomain() + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + if err := h.svc.Create(r.Context(), cert); err != nil { + slog.Error("creating employment certificate", "error", err) + mapDomainError(w, err) + return + } + respondJSON(w, http.StatusCreated, employmentCertFromDomain(cert)) +} + +// UpdateCertificate handles PUT /certificates/{id}. +func (h *EmploymentHandler) UpdateCertificate(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid certificate ID") + return + } + var req employmentCertificateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + cert, err := req.toDomain() + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + cert.ID = id + if err := h.svc.Update(r.Context(), cert); err != nil { + slog.Error("updating employment certificate", "error", err, "id", id) + mapDomainError(w, err) + return + } + respondJSON(w, http.StatusOK, employmentCertFromDomain(cert)) +} + +// ConfirmCertificate handles POST /certificates/{id}/confirm. +func (h *EmploymentHandler) ConfirmCertificate(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid certificate ID") + return + } + if err := h.svc.Confirm(r.Context(), id); err != nil { + slog.Error("confirming employment certificate", "error", err, "id", id) + mapDomainError(w, err) + return + } + // Return refreshed certificate so callers can render the new status. + cert, err := h.svc.Get(r.Context(), id) + if err != nil { + slog.Error("fetching certificate after confirm", "error", err, "id", id) + mapDomainError(w, err) + return + } + respondJSON(w, http.StatusOK, employmentCertFromDomain(cert)) +} + +// DeleteCertificate handles DELETE /certificates/{id}. +func (h *EmploymentHandler) DeleteCertificate(w http.ResponseWriter, r *http.Request) { + id, err := parseID(r) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid certificate ID") + return + } + if err := h.svc.Delete(r.Context(), id); err != nil { + slog.Error("deleting employment certificate", "error", err, "id", id) + mapDomainError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- helpers --- + +// readYearQuery parses the required ?year= query parameter and writes a 400 +// response if it is missing or invalid. Returns (year, true) on success. +func readYearQuery(w http.ResponseWriter, r *http.Request) (int, bool) { + yearStr := r.URL.Query().Get("year") + if yearStr == "" { + respondError(w, http.StatusBadRequest, "year query parameter is required") + return 0, false + } + year, err := strconv.Atoi(yearStr) + if err != nil { + respondError(w, http.StatusBadRequest, "invalid year parameter") + return 0, false + } + return year, true +} diff --git a/internal/handler/employment_handler_test.go b/internal/handler/employment_handler_test.go new file mode 100644 index 0000000..b59e18e --- /dev/null +++ b/internal/handler/employment_handler_test.go @@ -0,0 +1,835 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/zajca/zfaktury/internal/domain" +) + +// --- Mock service --- + +type mockEmploymentService struct { + uploadResp *domain.EmploymentDocument + uploadErr error + uploadGot struct { + year int + kind string + filename string + contentType string + size int + } + + extractResp *domain.EmploymentCertificate + extractErr error + extractGot int64 + + listDocsResp []*domain.EmploymentDocument + listDocsErr error + listDocsYear int + + deleteDocErr error + deleteDocID int64 + + createErr error + createGot *domain.EmploymentCertificate + updateErr error + updateGot *domain.EmploymentCertificate + confirmErr error + confirmID int64 + + getResp *domain.EmploymentCertificate + getErr error + getID int64 + + listResp []*domain.EmploymentCertificate + listErr error + listYearGot int + deleteErr error + deleteCertID int64 +} + +func (m *mockEmploymentService) UploadDocument(_ context.Context, year int, kind, filename, contentType string, content io.Reader) (*domain.EmploymentDocument, error) { + m.uploadGot.year = year + m.uploadGot.kind = kind + m.uploadGot.filename = filename + m.uploadGot.contentType = contentType + if content != nil { + buf, _ := io.ReadAll(content) + m.uploadGot.size = len(buf) + } + if m.uploadErr != nil { + return nil, m.uploadErr + } + return m.uploadResp, nil +} + +func (m *mockEmploymentService) ExtractDocument(_ context.Context, docID int64) (*domain.EmploymentCertificate, error) { + m.extractGot = docID + if m.extractErr != nil { + return nil, m.extractErr + } + return m.extractResp, nil +} + +func (m *mockEmploymentService) ListDocumentsByYear(_ context.Context, year int) ([]*domain.EmploymentDocument, error) { + m.listDocsYear = year + return m.listDocsResp, m.listDocsErr +} + +func (m *mockEmploymentService) DeleteDocument(_ context.Context, id int64) error { + m.deleteDocID = id + return m.deleteDocErr +} + +func (m *mockEmploymentService) Create(_ context.Context, cert *domain.EmploymentCertificate) error { + if m.createErr != nil { + return m.createErr + } + cert.ID = 42 + cert.Status = "draft" + cert.CreatedAt = time.Date(2025, 5, 1, 12, 0, 0, 0, time.UTC) + cert.UpdatedAt = cert.CreatedAt + m.createGot = cert + return nil +} + +func (m *mockEmploymentService) Update(_ context.Context, cert *domain.EmploymentCertificate) error { + if m.updateErr != nil { + return m.updateErr + } + cert.UpdatedAt = time.Date(2025, 5, 2, 12, 0, 0, 0, time.UTC) + m.updateGot = cert + return nil +} + +func (m *mockEmploymentService) Confirm(_ context.Context, certID int64) error { + m.confirmID = certID + return m.confirmErr +} + +func (m *mockEmploymentService) Get(_ context.Context, certID int64) (*domain.EmploymentCertificate, error) { + m.getID = certID + if m.getErr != nil { + return nil, m.getErr + } + return m.getResp, nil +} + +func (m *mockEmploymentService) ListByYear(_ context.Context, year int) ([]*domain.EmploymentCertificate, error) { + m.listYearGot = year + return m.listResp, m.listErr +} + +func (m *mockEmploymentService) Delete(_ context.Context, certID int64) error { + m.deleteCertID = certID + return m.deleteErr +} + +// --- Test helpers --- + +func mountEmployment(svc employmentService) *chi.Mux { + h := NewEmploymentHandler(svc) + r := chi.NewRouter() + r.Route("/api/v1/tax/employment", func(api chi.Router) { + api.Mount("/", h.Routes()) + }) + return r +} + +func sampleEmploymentDoc() *domain.EmploymentDocument { + return &domain.EmploymentDocument{ + ID: 1, + Year: 2025, + Kind: domain.EmploymentDocAdvance, + Filename: "potvrzeni.pdf", + ContentType: "application/pdf", + Size: 512, + ExtractionStatus: "pending", + CreatedAt: time.Date(2025, 4, 1, 9, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 4, 1, 9, 0, 0, 0, time.UTC), + } +} + +func sampleEmploymentCert() *domain.EmploymentCertificate { + return &domain.EmploymentCertificate{ + ID: 10, + Year: 2025, + CertificateType: domain.CertificateAdvance, + EmployerName: "ACME s.r.o.", + EmployerICO: "12345678", + EmployerAddress: "Prague", + ContractType: domain.ContractHPP, + PeriodFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + GrossIncome: 12000000, // 120 000 CZK + AdvanceTaxWithheld: 1800000, // 18 000 CZK + Status: "draft", + CreatedAt: time.Date(2025, 4, 1, 9, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2025, 4, 1, 9, 0, 0, 0, time.UTC), + } +} + +// buildEmploymentUploadRequest creates a multipart upload request for the +// employment documents endpoint. +func buildEmploymentUploadRequest(t *testing.T, filename, fileContentType string, content []byte, query string) *http.Request { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + h := make(map[string][]string) + h["Content-Disposition"] = []string{fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename)} + h["Content-Type"] = []string{fileContentType} + part, err := mw.CreatePart(h) + if err != nil { + t.Fatalf("creating form part: %v", err) + } + if _, err := io.Copy(part, bytes.NewReader(content)); err != nil { + t.Fatalf("copying file content: %v", err) + } + mw.Close() + + url := "/api/v1/tax/employment/documents" + if query != "" { + url += "?" + query + } + req := httptest.NewRequest(http.MethodPost, url, &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + return req +} + +// --- Upload tests --- + +func TestEmploymentHandler_UploadDocument_OK(t *testing.T) { + svc := &mockEmploymentService{uploadResp: sampleEmploymentDoc()} + r := mountEmployment(svc) + + pdfContent := append([]byte("%PDF-1.4 "), bytes.Repeat([]byte{0x00}, 503)...) + req := buildEmploymentUploadRequest(t, "potvrzeni.pdf", "application/pdf", pdfContent, "year=2025&kind=advance") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d, body = %s", w.Code, http.StatusCreated, w.Body.String()) + } + var resp employmentDocumentResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decoding: %v", err) + } + if resp.ID != 1 || resp.Filename != "potvrzeni.pdf" { + t.Errorf("unexpected response: %+v", resp) + } + if svc.uploadGot.year != 2025 || svc.uploadGot.kind != "advance" { + t.Errorf("svc args = %+v", svc.uploadGot) + } + if svc.uploadGot.contentType != "application/pdf" { + t.Errorf("contentType = %q, want application/pdf", svc.uploadGot.contentType) + } + if svc.uploadGot.size != len(pdfContent) { + t.Errorf("size = %d, want %d", svc.uploadGot.size, len(pdfContent)) + } +} + +func TestEmploymentHandler_UploadDocument_DefaultsKindToAdvance(t *testing.T) { + svc := &mockEmploymentService{uploadResp: sampleEmploymentDoc()} + r := mountEmployment(svc) + + req := buildEmploymentUploadRequest(t, "doc.pdf", "application/pdf", []byte("%PDF-1.4 "), "year=2025") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if svc.uploadGot.kind != "advance" { + t.Errorf("kind = %q, want advance (default)", svc.uploadGot.kind) + } +} + +func TestEmploymentHandler_UploadDocument_MissingYear(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + req := buildEmploymentUploadRequest(t, "doc.pdf", "application/pdf", []byte("%PDF-1.4"), "") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_UploadDocument_InvalidYear(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + req := buildEmploymentUploadRequest(t, "doc.pdf", "application/pdf", []byte("%PDF-1.4"), "year=abc") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_UploadDocument_MissingFile(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("year", "2025") + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/documents?year=2025", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEmploymentHandler_UploadDocument_InvalidMultipart(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/documents?year=2025", strings.NewReader("not a multipart body")) + req.Header.Set("Content-Type", "multipart/form-data; boundary=xyz") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// MIME rejection: service rejects with ErrInvalidInput → 400. +func TestEmploymentHandler_UploadDocument_MIMERejection(t *testing.T) { + svc := &mockEmploymentService{ + uploadErr: fmt.Errorf("unsupported MIME type: %w", domain.ErrInvalidInput), + } + r := mountEmployment(svc) + + req := buildEmploymentUploadRequest(t, "evil.txt", "text/plain", []byte("hello"), "year=2025&kind=advance") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (invalid input)", w.Code) + } +} + +// Oversize rejection: client tries to send > 10 MB body. The MaxBytesReader +// kicks in during ParseMultipartForm and the handler should answer 400. +func TestEmploymentHandler_UploadDocument_OversizeRejection(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + // 11 MB to exceed the 10 MB cap. + oversize := bytes.Repeat([]byte{0x42}, (10<<20)+(1<<20)) + req := buildEmploymentUploadRequest(t, "big.pdf", "application/pdf", oversize, "year=2025&kind=advance") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (oversize)", w.Code) + } + if svc.uploadGot.year != 0 { + t.Errorf("svc.UploadDocument should not have been called for oversize body, got year=%d", svc.uploadGot.year) + } +} + +// --- Extract test --- + +func TestEmploymentHandler_ExtractDocument_OK(t *testing.T) { + svc := &mockEmploymentService{extractResp: sampleEmploymentCert()} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/documents/7/extract", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if svc.extractGot != 7 { + t.Errorf("extractGot = %d, want 7", svc.extractGot) + } + var resp employmentCertificateResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decoding: %v", err) + } + if resp.EmployerICO != "12345678" { + t.Errorf("EmployerICO = %q", resp.EmployerICO) + } + if resp.GrossIncomeCZK != 120000 { + t.Errorf("GrossIncomeCZK = %f, want 120000", resp.GrossIncomeCZK) + } +} + +func TestEmploymentHandler_ExtractDocument_InvalidID(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/documents/abc/extract", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_ExtractDocument_NotFound(t *testing.T) { + svc := &mockEmploymentService{ + extractErr: fmt.Errorf("loading document: %w", domain.ErrNotFound), + } + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/documents/9999/extract", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +// --- Document list/delete --- + +func TestEmploymentHandler_ListDocuments_OK(t *testing.T) { + svc := &mockEmploymentService{listDocsResp: []*domain.EmploymentDocument{sampleEmploymentDoc()}} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tax/employment/documents?year=2025", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var items []employmentDocumentResponse + if err := json.NewDecoder(w.Body).Decode(&items); err != nil { + t.Fatalf("decoding: %v", err) + } + if len(items) != 1 { + t.Errorf("len = %d, want 1", len(items)) + } + if svc.listDocsYear != 2025 { + t.Errorf("listDocsYear = %d", svc.listDocsYear) + } +} + +func TestEmploymentHandler_ListDocuments_MissingYear(t *testing.T) { + r := mountEmployment(&mockEmploymentService{}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tax/employment/documents", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_DeleteDocument_OK(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tax/employment/documents/3", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", w.Code) + } + if svc.deleteDocID != 3 { + t.Errorf("deleteDocID = %d, want 3", svc.deleteDocID) + } +} + +func TestEmploymentHandler_DeleteDocument_InvalidID(t *testing.T) { + r := mountEmployment(&mockEmploymentService{}) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tax/employment/documents/abc", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_DeleteDocument_NotFound(t *testing.T) { + svc := &mockEmploymentService{deleteDocErr: fmt.Errorf("delete: %w", domain.ErrNotFound)} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tax/employment/documents/99", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +// --- Certificate CRUD --- + +func TestEmploymentHandler_CreateCertificate_OK(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + body := `{ + "year": 2025, + "certificate_type": "advance", + "employer_name": "ACME s.r.o.", + "employer_ico": "12345678", + "contract_type": "hpp", + "period_from": "2025-01-01", + "period_to": "2025-12-31", + "gross_income_czk": 120000, + "advance_tax_withheld_czk": 18000 + }` + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/certificates", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if svc.createGot == nil { + t.Fatal("Create not called") + } + if svc.createGot.GrossIncome != 12000000 { + t.Errorf("GrossIncome (halere) = %d, want 12000000", svc.createGot.GrossIncome) + } + if svc.createGot.AdvanceTaxWithheld != 1800000 { + t.Errorf("AdvanceTaxWithheld (halere) = %d, want 1800000", svc.createGot.AdvanceTaxWithheld) + } + if svc.createGot.CertificateType != domain.CertificateAdvance { + t.Errorf("CertificateType = %q", svc.createGot.CertificateType) + } + if !svc.createGot.PeriodFrom.Equal(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) { + t.Errorf("PeriodFrom = %v", svc.createGot.PeriodFrom) + } +} + +func TestEmploymentHandler_CreateCertificate_InvalidJSON(t *testing.T) { + r := mountEmployment(&mockEmploymentService{}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/certificates", strings.NewReader("not json")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_CreateCertificate_InvalidDate(t *testing.T) { + r := mountEmployment(&mockEmploymentService{}) + + body := `{"year":2025,"certificate_type":"advance","period_from":"01-01-2025","period_to":"2025-12-31"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/certificates", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_CreateCertificate_ValidationError(t *testing.T) { + svc := &mockEmploymentService{ + createErr: fmt.Errorf("invalid IČO: %w", domain.ErrInvalidInput), + } + r := mountEmployment(svc) + + body := `{ + "year": 2025, + "certificate_type": "advance", + "period_from": "2025-01-01", + "period_to": "2025-12-31" + }` + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/certificates", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (invalid input)", w.Code) + } +} + +func TestEmploymentHandler_UpdateCertificate_OK(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + body := `{ + "year": 2025, + "certificate_type": "withholding", + "contract_type": "dpp", + "period_from": "2025-06-01", + "period_to": "2025-06-30", + "gross_income_czk": 9500, + "withheld_final_tax_czk": 1425, + "include_withholding_in_dap": true + }` + req := httptest.NewRequest(http.MethodPut, "/api/v1/tax/employment/certificates/55", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if svc.updateGot == nil { + t.Fatal("Update not called") + } + if svc.updateGot.ID != 55 { + t.Errorf("Update ID = %d, want 55", svc.updateGot.ID) + } + if svc.updateGot.WithheldFinalTax != 142500 { + t.Errorf("WithheldFinalTax = %d, want 142500", svc.updateGot.WithheldFinalTax) + } + if !svc.updateGot.IncludeWithholdingInDAP { + t.Errorf("IncludeWithholdingInDAP = false, want true") + } +} + +func TestEmploymentHandler_UpdateCertificate_InvalidID(t *testing.T) { + r := mountEmployment(&mockEmploymentService{}) + + body := `{"year":2025,"certificate_type":"advance","period_from":"2025-01-01","period_to":"2025-12-31"}` + req := httptest.NewRequest(http.MethodPut, "/api/v1/tax/employment/certificates/abc", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_UpdateCertificate_NotFound(t *testing.T) { + svc := &mockEmploymentService{updateErr: fmt.Errorf("update: %w", domain.ErrNotFound)} + r := mountEmployment(svc) + + body := `{"year":2025,"certificate_type":"advance","period_from":"2025-01-01","period_to":"2025-12-31"}` + req := httptest.NewRequest(http.MethodPut, "/api/v1/tax/employment/certificates/9999", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestEmploymentHandler_GetCertificate_OK(t *testing.T) { + svc := &mockEmploymentService{getResp: sampleEmploymentCert()} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tax/employment/certificates/10", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if svc.getID != 10 { + t.Errorf("getID = %d, want 10", svc.getID) + } + var resp employmentCertificateResponse + json.NewDecoder(w.Body).Decode(&resp) + if resp.ID != 10 { + t.Errorf("ID = %d, want 10", resp.ID) + } +} + +func TestEmploymentHandler_GetCertificate_NotFound(t *testing.T) { + svc := &mockEmploymentService{getErr: fmt.Errorf("get: %w", domain.ErrNotFound)} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tax/employment/certificates/99", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestEmploymentHandler_ListCertificates_OK(t *testing.T) { + svc := &mockEmploymentService{ + listResp: []*domain.EmploymentCertificate{sampleEmploymentCert()}, + } + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tax/employment/certificates?year=2025", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var resp []employmentCertificateResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decoding: %v", err) + } + if len(resp) != 1 { + t.Errorf("len = %d, want 1", len(resp)) + } + if svc.listYearGot != 2025 { + t.Errorf("listYearGot = %d", svc.listYearGot) + } +} + +func TestEmploymentHandler_ListCertificates_MissingYear(t *testing.T) { + r := mountEmployment(&mockEmploymentService{}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/tax/employment/certificates", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_ConfirmCertificate_OK(t *testing.T) { + svc := &mockEmploymentService{getResp: sampleEmploymentCert()} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/certificates/10/confirm", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + if svc.confirmID != 10 { + t.Errorf("confirmID = %d, want 10", svc.confirmID) + } + if svc.getID != 10 { + t.Errorf("expected Get to be called after confirm; getID = %d", svc.getID) + } +} + +func TestEmploymentHandler_ConfirmCertificate_InvalidID(t *testing.T) { + r := mountEmployment(&mockEmploymentService{}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/certificates/abc/confirm", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_ConfirmCertificate_ValidationError(t *testing.T) { + svc := &mockEmploymentService{ + confirmErr: fmt.Errorf("confirm: %w", domain.ErrInvalidInput), + } + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/tax/employment/certificates/10/confirm", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_DeleteCertificate_OK(t *testing.T) { + svc := &mockEmploymentService{} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tax/employment/certificates/10", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", w.Code) + } + if svc.deleteCertID != 10 { + t.Errorf("deleteCertID = %d, want 10", svc.deleteCertID) + } +} + +func TestEmploymentHandler_DeleteCertificate_InvalidID(t *testing.T) { + r := mountEmployment(&mockEmploymentService{}) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tax/employment/certificates/abc", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestEmploymentHandler_DeleteCertificate_NotFound(t *testing.T) { + svc := &mockEmploymentService{deleteErr: fmt.Errorf("delete: %w", domain.ErrNotFound)} + r := mountEmployment(svc) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/tax/employment/certificates/9999", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +// --- Internal: amount conversion --- + +func TestEmploymentHandler_AmountFromCZK_Rounding(t *testing.T) { + cases := []struct { + in float64 + want domain.Amount + }{ + {0, 0}, + {1, 100}, + {1.5, 150}, + {12345.67, 1234567}, + {0.005, 1}, // half-away-from-zero + {-1.5, -150}, + {-12345.67, -1234567}, + } + for _, c := range cases { + got := amountFromCZK(c.in) + if got != c.want { + t.Errorf("amountFromCZK(%v) = %d, want %d", c.in, got, c.want) + } + } +} + +// Ensure the mock satisfies the handler-side interface. +var _ employmentService = (*mockEmploymentService)(nil) + +// errors.Is sanity for the wrapped errors used in tests above. +func TestEmploymentHandler_ErrorMappingSanity(t *testing.T) { + wrapped := fmt.Errorf("ctx: %w", domain.ErrNotFound) + if !errors.Is(wrapped, domain.ErrNotFound) { + t.Fatal("expected wrapped error to match ErrNotFound via errors.Is") + } +} diff --git a/internal/handler/income_tax_handler.go b/internal/handler/income_tax_handler.go index 53d677f..044a930 100644 --- a/internal/handler/income_tax_handler.go +++ b/internal/handler/income_tax_handler.go @@ -94,7 +94,13 @@ type incomeTaxResponse struct { OtherIncomeExempt int64 `json:"other_income_exempt"` OtherIncomeNet int64 `json:"other_income_net"` - HasXML bool `json:"has_xml"` + HasXML bool `json:"has_xml"` + + // Warnings surfaces non-blocking advisory tokens raised during Recalculate + // (e.g. progressive_rate_review). The frontend translates each token into + // localised copy. + Warnings []string `json:"warnings"` + Status string `json:"status"` FiledAt *string `json:"filed_at,omitempty"` CreatedAt string `json:"created_at"` @@ -144,6 +150,7 @@ func incomeTaxFromDomain(itr *domain.IncomeTaxReturn) incomeTaxResponse { OtherIncomeNet: int64(itr.OtherIncomeNet), HasXML: len(itr.XMLData) > 0, + Warnings: itr.Warnings, Status: itr.Status, FiledAt: formatOptionalTime(itr.FiledAt), CreatedAt: itr.CreatedAt.Format(time.RFC3339), diff --git a/internal/handler/router.go b/internal/handler/router.go index 6c5bd29..e96e819 100644 --- a/internal/handler/router.go +++ b/internal/handler/router.go @@ -55,6 +55,7 @@ func NewRouter( investmentIncomeSvc *service.InvestmentIncomeService, investmentDocSvc *service.InvestmentDocumentService, investmentExtractionSvc *service.InvestmentExtractionService, + employmentSvc *service.EmploymentCertificateService, invDocumentSvc *service.InvoiceDocumentService, fakturoidImportSvc *service.FakturoidImportService, dashboardSvc *service.DashboardService, @@ -238,6 +239,11 @@ func NewRouter( api.Mount("/investments", investmentHandler.Routes()) } + if employmentSvc != nil { + employmentHandler := NewEmploymentHandler(employmentSvc) + api.Mount("/tax/employment", employmentHandler.Routes()) + } + fakturoidHandler := NewFakturoidHandler(fakturoidImportSvc) api.Mount("/import/fakturoid", fakturoidHandler.Routes()) diff --git a/internal/repository/employment_certificate_repo.go b/internal/repository/employment_certificate_repo.go new file mode 100644 index 0000000..bb673ef --- /dev/null +++ b/internal/repository/employment_certificate_repo.go @@ -0,0 +1,266 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/zajca/zfaktury/internal/domain" +) + +// EmploymentCertificateRepository handles persistence of EmploymentCertificate entities. +type EmploymentCertificateRepository struct { + db *sql.DB +} + +// NewEmploymentCertificateRepository creates a new EmploymentCertificateRepository. +func NewEmploymentCertificateRepository(db *sql.DB) *EmploymentCertificateRepository { + return &EmploymentCertificateRepository{db: db} +} + +// employmentCertificateColumns is the list of columns to select for an +// EmploymentCertificate row. +const employmentCertificateColumns = `id, year, document_id, certificate_type, + employer_name, employer_ico, employer_address, contract_type, + period_from, period_to, + gross_income, income_without_advance, foreign_tax_paid, + advance_tax_withheld, annual_settlement_refund, monthly_bonus_paid, + withheld_final_tax, include_withholding_in_dap, + notes, status, deleted_at, created_at, updated_at` + +// scanEmploymentCertificate scans an EmploymentCertificate from a row. +func scanEmploymentCertificate(s scanner) (*domain.EmploymentCertificate, error) { + c := &domain.EmploymentCertificate{} + var documentID sql.NullInt64 + var certificateType, contractType string + var periodFromStr, periodToStr string + var includeWithholding int + var deletedAtStr sql.NullString + var createdAtStr, updatedAtStr string + + err := s.Scan( + &c.ID, &c.Year, &documentID, &certificateType, + &c.EmployerName, &c.EmployerICO, &c.EmployerAddress, &contractType, + &periodFromStr, &periodToStr, + &c.GrossIncome, &c.IncomeWithoutAdvance, &c.ForeignTaxPaid, + &c.AdvanceTaxWithheld, &c.AnnualSettlementRefund, &c.MonthlyBonusPaid, + &c.WithheldFinalTax, &includeWithholding, + &c.Notes, &c.Status, &deletedAtStr, &createdAtStr, &updatedAtStr, + ) + if err != nil { + return nil, err + } + + if documentID.Valid { + id := documentID.Int64 + c.DocumentID = &id + } + c.CertificateType = domain.CertificateType(certificateType) + c.ContractType = domain.ContractType(contractType) + c.IncludeWithholdingInDAP = includeWithholding != 0 + + c.PeriodFrom, err = parseDate(time.DateOnly, periodFromStr) + if err != nil { + return nil, fmt.Errorf("scanning employment_certificate period_from: %w", err) + } + c.PeriodTo, err = parseDate(time.DateOnly, periodToStr) + if err != nil { + return nil, fmt.Errorf("scanning employment_certificate period_to: %w", err) + } + c.DeletedAt, err = parseDatePtr(time.RFC3339, deletedAtStr) + if err != nil { + return nil, fmt.Errorf("scanning employment_certificate deleted_at: %w", err) + } + c.CreatedAt, err = parseDate(time.RFC3339, createdAtStr) + if err != nil { + return nil, fmt.Errorf("scanning employment_certificate created_at: %w", err) + } + c.UpdatedAt, err = parseDate(time.RFC3339, updatedAtStr) + if err != nil { + return nil, fmt.Errorf("scanning employment_certificate updated_at: %w", err) + } + + return c, nil +} + +// Create inserts a new employment certificate into the database. The UNIQUE +// (year, employer_ico, certificate_type, period_from, period_to) constraint +// uses ON CONFLICT REPLACE — re-inserting with the same key overwrites the +// existing row and changes its primary key. +func (r *EmploymentCertificateRepository) Create(ctx context.Context, cert *domain.EmploymentCertificate) error { + now := time.Now() + cert.CreatedAt = now + cert.UpdatedAt = now + + if cert.CertificateType == "" { + cert.CertificateType = domain.CertificateAdvance + } + if cert.ContractType == "" { + cert.ContractType = domain.ContractDPC + } + if cert.Status == "" { + cert.Status = "draft" + } + + var documentID any + if cert.DocumentID != nil { + documentID = *cert.DocumentID + } + + includeWithholding := 0 + if cert.IncludeWithholdingInDAP { + includeWithholding = 1 + } + + result, err := r.db.ExecContext(ctx, ` + INSERT INTO employment_income_certificates ( + year, document_id, certificate_type, + employer_name, employer_ico, employer_address, contract_type, + period_from, period_to, + gross_income, income_without_advance, foreign_tax_paid, + advance_tax_withheld, annual_settlement_refund, monthly_bonus_paid, + withheld_final_tax, include_withholding_in_dap, + notes, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + cert.Year, documentID, string(cert.CertificateType), + cert.EmployerName, cert.EmployerICO, cert.EmployerAddress, string(cert.ContractType), + cert.PeriodFrom.Format(time.DateOnly), cert.PeriodTo.Format(time.DateOnly), + cert.GrossIncome, cert.IncomeWithoutAdvance, cert.ForeignTaxPaid, + cert.AdvanceTaxWithheld, cert.AnnualSettlementRefund, cert.MonthlyBonusPaid, + cert.WithheldFinalTax, includeWithholding, + cert.Notes, cert.Status, + cert.CreatedAt.Format(time.RFC3339), cert.UpdatedAt.Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("inserting employment_certificate: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("getting last insert id for employment_certificate: %w", err) + } + cert.ID = id + return nil +} + +// GetByID retrieves a non-deleted employment certificate by its ID. +func (r *EmploymentCertificateRepository) GetByID(ctx context.Context, id int64) (*domain.EmploymentCertificate, error) { + row := r.db.QueryRowContext(ctx, + `SELECT `+employmentCertificateColumns+` FROM employment_income_certificates WHERE id = ? AND deleted_at IS NULL`, + id, + ) + cert, err := scanEmploymentCertificate(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("employment_certificate %d: %w", id, domain.ErrNotFound) + } + return nil, fmt.Errorf("querying employment_certificate %d: %w", id, err) + } + return cert, nil +} + +// Update modifies an existing employment certificate. +func (r *EmploymentCertificateRepository) Update(ctx context.Context, cert *domain.EmploymentCertificate) error { + cert.UpdatedAt = time.Now() + + var documentID any + if cert.DocumentID != nil { + documentID = *cert.DocumentID + } + + includeWithholding := 0 + if cert.IncludeWithholdingInDAP { + includeWithholding = 1 + } + + result, err := r.db.ExecContext(ctx, ` + UPDATE employment_income_certificates SET + year = ?, document_id = ?, certificate_type = ?, + employer_name = ?, employer_ico = ?, employer_address = ?, contract_type = ?, + period_from = ?, period_to = ?, + gross_income = ?, income_without_advance = ?, foreign_tax_paid = ?, + advance_tax_withheld = ?, annual_settlement_refund = ?, monthly_bonus_paid = ?, + withheld_final_tax = ?, include_withholding_in_dap = ?, + notes = ?, status = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL`, + cert.Year, documentID, string(cert.CertificateType), + cert.EmployerName, cert.EmployerICO, cert.EmployerAddress, string(cert.ContractType), + cert.PeriodFrom.Format(time.DateOnly), cert.PeriodTo.Format(time.DateOnly), + cert.GrossIncome, cert.IncomeWithoutAdvance, cert.ForeignTaxPaid, + cert.AdvanceTaxWithheld, cert.AnnualSettlementRefund, cert.MonthlyBonusPaid, + cert.WithheldFinalTax, includeWithholding, + cert.Notes, cert.Status, + cert.UpdatedAt.Format(time.RFC3339), cert.ID, + ) + if err != nil { + return fmt.Errorf("updating employment_certificate %d: %w", cert.ID, err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected for employment_certificate %d: %w", cert.ID, err) + } + if rows == 0 { + return fmt.Errorf("employment_certificate %d: %w", cert.ID, domain.ErrNotFound) + } + return nil +} + +// Delete performs a soft delete on an employment certificate. +func (r *EmploymentCertificateRepository) Delete(ctx context.Context, id int64) error { + now := time.Now() + result, err := r.db.ExecContext(ctx, + `UPDATE employment_income_certificates SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`, + now.Format(time.RFC3339), now.Format(time.RFC3339), id, + ) + if err != nil { + return fmt.Errorf("soft-deleting employment_certificate %d: %w", id, err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected for employment_certificate %d delete: %w", id, err) + } + if rows == 0 { + return fmt.Errorf("employment_certificate %d: %w", id, domain.ErrNotFound) + } + return nil +} + +// ListByYear retrieves all non-deleted certificates for a year, ordered by +// employer name and period. +func (r *EmploymentCertificateRepository) ListByYear(ctx context.Context, year int) ([]*domain.EmploymentCertificate, error) { + return r.queryList(ctx, `WHERE year = ? AND deleted_at IS NULL ORDER BY employer_name, period_from`, year) +} + +// ListConfirmedByYear retrieves all non-deleted certificates with status = +// 'confirmed' for a year. Used by Recalculate to aggregate §6 totals. +func (r *EmploymentCertificateRepository) ListConfirmedByYear(ctx context.Context, year int) ([]*domain.EmploymentCertificate, error) { + return r.queryList(ctx, `WHERE year = ? AND status = 'confirmed' AND deleted_at IS NULL ORDER BY employer_name, period_from`, year) +} + +func (r *EmploymentCertificateRepository) queryList(ctx context.Context, where string, args ...any) ([]*domain.EmploymentCertificate, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+employmentCertificateColumns+` FROM employment_income_certificates `+where, + args..., + ) + if err != nil { + return nil, fmt.Errorf("listing employment_certificates: %w", err) + } + defer func() { _ = rows.Close() }() + + var result []*domain.EmploymentCertificate + for rows.Next() { + cert, err := scanEmploymentCertificate(rows) + if err != nil { + return nil, fmt.Errorf("scanning employment_certificate row: %w", err) + } + result = append(result, cert) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating employment_certificate rows: %w", err) + } + return result, nil +} diff --git a/internal/repository/employment_certificate_repo_test.go b/internal/repository/employment_certificate_repo_test.go new file mode 100644 index 0000000..f508a2b --- /dev/null +++ b/internal/repository/employment_certificate_repo_test.go @@ -0,0 +1,383 @@ +package repository + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/zajca/zfaktury/internal/domain" + "github.com/zajca/zfaktury/internal/testutil" +) + +func mustParseDay(t *testing.T, s string) time.Time { + t.Helper() + tt, err := time.Parse(time.DateOnly, s) + if err != nil { + t.Fatalf("parse day %q: %v", s, err) + } + return tt +} + +func seedEmploymentCertificate(t *testing.T, repo *EmploymentCertificateRepository, cert *domain.EmploymentCertificate) *domain.EmploymentCertificate { + t.Helper() + if cert == nil { + cert = &domain.EmploymentCertificate{} + } + if cert.Year == 0 { + cert.Year = 2025 + } + if cert.CertificateType == "" { + cert.CertificateType = domain.CertificateAdvance + } + if cert.ContractType == "" { + cert.ContractType = domain.ContractDPC + } + if cert.EmployerName == "" { + cert.EmployerName = "Acme s.r.o." + } + if cert.EmployerICO == "" { + cert.EmployerICO = "12345678" + } + if cert.PeriodFrom.IsZero() { + cert.PeriodFrom = mustParseDay(t, "2025-01-01") + } + if cert.PeriodTo.IsZero() { + cert.PeriodTo = mustParseDay(t, "2025-12-31") + } + if cert.Status == "" { + cert.Status = "draft" + } + + ctx := context.Background() + if err := repo.Create(ctx, cert); err != nil { + t.Fatalf("seedEmploymentCertificate: %v", err) + } + return cert +} + +func TestEmploymentCertificateRepository_Create(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + cert := &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + EmployerName: "Acme s.r.o.", + EmployerICO: "12345678", + EmployerAddress: "Praha 1", + ContractType: domain.ContractDPC, + PeriodFrom: mustParseDay(t, "2025-01-01"), + PeriodTo: mustParseDay(t, "2025-06-30"), + GrossIncome: 120_000_00, + AdvanceTaxWithheld: 18_000_00, + AnnualSettlementRefund: 1_000_00, + MonthlyBonusPaid: 7_650_00, + Status: "draft", + } + if err := repo.Create(ctx, cert); err != nil { + t.Fatalf("Create() error: %v", err) + } + if cert.ID == 0 { + t.Fatal("expected non-zero ID after Create") + } + + got, err := repo.GetByID(ctx, cert.ID) + if err != nil { + t.Fatalf("GetByID() error: %v", err) + } + if got.EmployerName != "Acme s.r.o." { + t.Errorf("EmployerName = %q", got.EmployerName) + } + if got.EmployerICO != "12345678" { + t.Errorf("EmployerICO = %q", got.EmployerICO) + } + if got.GrossIncome != 120_000_00 { + t.Errorf("GrossIncome = %d", got.GrossIncome) + } + if got.MonthlyBonusPaid != 7_650_00 { + t.Errorf("MonthlyBonusPaid = %d", got.MonthlyBonusPaid) + } + if !got.PeriodFrom.Equal(mustParseDay(t, "2025-01-01")) { + t.Errorf("PeriodFrom = %v", got.PeriodFrom) + } + if !got.PeriodTo.Equal(mustParseDay(t, "2025-06-30")) { + t.Errorf("PeriodTo = %v", got.PeriodTo) + } + if got.IncludeWithholdingInDAP { + t.Error("expected IncludeWithholdingInDAP=false by default") + } +} + +func TestEmploymentCertificateRepository_Create_WithDocumentID(t *testing.T) { + db := testutil.NewTestDB(t) + docRepo := NewEmploymentDocumentRepository(db) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + doc := seedEmploymentDocument(t, docRepo, nil) + + cert := &domain.EmploymentCertificate{ + Year: 2025, + DocumentID: &doc.ID, + CertificateType: domain.CertificateAdvance, + EmployerName: "Acme", + EmployerICO: "12345678", + PeriodFrom: mustParseDay(t, "2025-01-01"), + PeriodTo: mustParseDay(t, "2025-12-31"), + Status: "draft", + } + if err := repo.Create(ctx, cert); err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := repo.GetByID(ctx, cert.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.DocumentID == nil || *got.DocumentID != doc.ID { + t.Errorf("DocumentID = %v, want %d", got.DocumentID, doc.ID) + } +} + +func TestEmploymentCertificateRepository_GetByID_NotFound(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + _, err := repo.GetByID(ctx, 99999) + if !errors.Is(err, domain.ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestEmploymentCertificateRepository_Update(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + cert := seedEmploymentCertificate(t, repo, nil) + cert.GrossIncome = 200_000_00 + cert.IncludeWithholdingInDAP = true + cert.Status = "confirmed" + + if err := repo.Update(ctx, cert); err != nil { + t.Fatalf("Update: %v", err) + } + + got, err := repo.GetByID(ctx, cert.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.GrossIncome != 200_000_00 { + t.Errorf("GrossIncome = %d", got.GrossIncome) + } + if !got.IncludeWithholdingInDAP { + t.Error("expected IncludeWithholdingInDAP=true after update") + } + if got.Status != "confirmed" { + t.Errorf("Status = %q, want confirmed", got.Status) + } +} + +func TestEmploymentCertificateRepository_Update_NotFound(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + cert := &domain.EmploymentCertificate{ + ID: 99999, + Year: 2025, + CertificateType: domain.CertificateAdvance, + EmployerICO: "12345678", + ContractType: domain.ContractDPC, + PeriodFrom: mustParseDay(t, "2025-01-01"), + PeriodTo: mustParseDay(t, "2025-12-31"), + Status: "draft", + } + err := repo.Update(ctx, cert) + if !errors.Is(err, domain.ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestEmploymentCertificateRepository_Delete_SoftDelete(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + cert := seedEmploymentCertificate(t, repo, nil) + + if err := repo.Delete(ctx, cert.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + + // GetByID excludes soft-deleted. + if _, err := repo.GetByID(ctx, cert.ID); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("expected ErrNotFound after soft delete, got %v", err) + } + + // ListByYear should not return it. + list, err := repo.ListByYear(ctx, 2025) + if err != nil { + t.Fatalf("ListByYear: %v", err) + } + if len(list) != 0 { + t.Errorf("expected empty list after soft delete, got %d", len(list)) + } +} + +func TestEmploymentCertificateRepository_Delete_NotFound(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + if err := repo.Delete(ctx, 99999); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestEmploymentCertificateRepository_ListByYear(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + seedEmploymentCertificate(t, repo, &domain.EmploymentCertificate{ + Year: 2025, EmployerName: "B", EmployerICO: "11111111", + PeriodFrom: mustParseDay(t, "2025-01-01"), PeriodTo: mustParseDay(t, "2025-06-30"), + }) + seedEmploymentCertificate(t, repo, &domain.EmploymentCertificate{ + Year: 2025, EmployerName: "A", EmployerICO: "22222222", + PeriodFrom: mustParseDay(t, "2025-01-01"), PeriodTo: mustParseDay(t, "2025-06-30"), + }) + seedEmploymentCertificate(t, repo, &domain.EmploymentCertificate{ + Year: 2024, EmployerName: "Old", EmployerICO: "33333333", + PeriodFrom: mustParseDay(t, "2024-01-01"), PeriodTo: mustParseDay(t, "2024-12-31"), + }) + + list, err := repo.ListByYear(ctx, 2025) + if err != nil { + t.Fatalf("ListByYear: %v", err) + } + if len(list) != 2 { + t.Fatalf("len(list) = %d, want 2", len(list)) + } + // Ordered by employer_name. + if list[0].EmployerName != "A" { + t.Errorf("ordering: list[0].EmployerName = %q, want A", list[0].EmployerName) + } +} + +func TestEmploymentCertificateRepository_ListConfirmedByYear(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + confirmed := seedEmploymentCertificate(t, repo, &domain.EmploymentCertificate{ + Year: 2025, EmployerName: "A", EmployerICO: "11111111", + PeriodFrom: mustParseDay(t, "2025-01-01"), PeriodTo: mustParseDay(t, "2025-06-30"), + Status: "draft", + }) + confirmed.Status = "confirmed" + if err := repo.Update(ctx, confirmed); err != nil { + t.Fatalf("Update: %v", err) + } + // Draft cert should be excluded. + seedEmploymentCertificate(t, repo, &domain.EmploymentCertificate{ + Year: 2025, EmployerName: "B", EmployerICO: "22222222", + PeriodFrom: mustParseDay(t, "2025-01-01"), PeriodTo: mustParseDay(t, "2025-06-30"), + Status: "draft", + }) + + list, err := repo.ListConfirmedByYear(ctx, 2025) + if err != nil { + t.Fatalf("ListConfirmedByYear: %v", err) + } + if len(list) != 1 { + t.Fatalf("len(list) = %d, want 1", len(list)) + } + if list[0].ID != confirmed.ID { + t.Errorf("expected cert %d, got %d", confirmed.ID, list[0].ID) + } +} + +func TestEmploymentCertificateRepository_ListConfirmedByYear_ExcludesDeleted(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + cert := seedEmploymentCertificate(t, repo, &domain.EmploymentCertificate{ + Year: 2025, EmployerName: "A", EmployerICO: "11111111", + PeriodFrom: mustParseDay(t, "2025-01-01"), PeriodTo: mustParseDay(t, "2025-06-30"), + Status: "confirmed", + }) + + if err := repo.Delete(ctx, cert.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + + list, err := repo.ListConfirmedByYear(ctx, 2025) + if err != nil { + t.Fatalf("ListConfirmedByYear: %v", err) + } + if len(list) != 0 { + t.Errorf("expected empty list, got %d", len(list)) + } +} + +// TestEmploymentCertificateRepository_UniqueReplace verifies that the UNIQUE +// (year, employer_ico, certificate_type, period_from, period_to) ON CONFLICT +// REPLACE clause overwrites the original row when the same key is reused. The +// new row gets a fresh primary key, but only one row remains for the unique +// tuple. +func TestEmploymentCertificateRepository_UniqueReplace(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentCertificateRepository(db) + ctx := context.Background() + + first := seedEmploymentCertificate(t, repo, &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + EmployerName: "Old name", + EmployerICO: "12345678", + PeriodFrom: mustParseDay(t, "2025-01-01"), + PeriodTo: mustParseDay(t, "2025-12-31"), + GrossIncome: 50_000_00, + Status: "confirmed", + }) + + // Insert again with same UNIQUE tuple but different values. + second := &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + EmployerName: "Corrected name", + EmployerICO: "12345678", + PeriodFrom: mustParseDay(t, "2025-01-01"), + PeriodTo: mustParseDay(t, "2025-12-31"), + GrossIncome: 75_000_00, + Status: "draft", + } + if err := repo.Create(ctx, second); err != nil { + t.Fatalf("Create (second): %v", err) + } + + // Only one row remains — the original is gone. + list, err := repo.ListByYear(ctx, 2025) + if err != nil { + t.Fatalf("ListByYear: %v", err) + } + if len(list) != 1 { + t.Fatalf("len(list) = %d, want 1 (UNIQUE REPLACE should keep only one row)", len(list)) + } + if list[0].EmployerName != "Corrected name" { + t.Errorf("EmployerName = %q, want %q (latest values should win)", list[0].EmployerName, "Corrected name") + } + if list[0].GrossIncome != 75_000_00 { + t.Errorf("GrossIncome = %d, want 7500000", list[0].GrossIncome) + } + // Sanity: original ID is gone. + if _, err := repo.GetByID(ctx, first.ID); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("expected ErrNotFound for replaced original (id=%d), got %v", first.ID, err) + } +} diff --git a/internal/repository/employment_document_repo.go b/internal/repository/employment_document_repo.go new file mode 100644 index 0000000..7b1084d --- /dev/null +++ b/internal/repository/employment_document_repo.go @@ -0,0 +1,166 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/zajca/zfaktury/internal/domain" +) + +// EmploymentDocumentRepository handles persistence of EmploymentDocument entities. +type EmploymentDocumentRepository struct { + db *sql.DB +} + +// NewEmploymentDocumentRepository creates a new EmploymentDocumentRepository. +func NewEmploymentDocumentRepository(db *sql.DB) *EmploymentDocumentRepository { + return &EmploymentDocumentRepository{db: db} +} + +// employmentDocumentColumns is the list of columns to select for an EmploymentDocument row. +const employmentDocumentColumns = `id, year, document_kind, filename, content_type, storage_path, size, extraction_status, extraction_error, created_at, updated_at` + +// scanEmploymentDocument scans an EmploymentDocument from a row. +func scanEmploymentDocument(s scanner) (*domain.EmploymentDocument, error) { + d := &domain.EmploymentDocument{} + var kind string + var extractionError sql.NullString + var createdAtStr, updatedAtStr string + + err := s.Scan( + &d.ID, &d.Year, &kind, &d.Filename, &d.ContentType, &d.StoragePath, + &d.Size, &d.ExtractionStatus, &extractionError, + &createdAtStr, &updatedAtStr, + ) + if err != nil { + return nil, err + } + + d.Kind = domain.EmploymentDocumentKind(kind) + if extractionError.Valid { + d.ExtractionError = extractionError.String + } + + d.CreatedAt, err = parseDate(time.RFC3339, createdAtStr) + if err != nil { + return nil, fmt.Errorf("scanning employment_document created_at: %w", err) + } + d.UpdatedAt, err = parseDate(time.RFC3339, updatedAtStr) + if err != nil { + return nil, fmt.Errorf("scanning employment_document updated_at: %w", err) + } + + return d, nil +} + +// Create inserts a new employment document into the database. +func (r *EmploymentDocumentRepository) Create(ctx context.Context, doc *domain.EmploymentDocument) error { + now := time.Now() + doc.CreatedAt = now + doc.UpdatedAt = now + + if doc.Kind == "" { + doc.Kind = domain.EmploymentDocAdvance + } + if doc.ExtractionStatus == "" { + doc.ExtractionStatus = domain.ExtractionPending + } + + result, err := r.db.ExecContext(ctx, ` + INSERT INTO employment_documents (year, document_kind, filename, content_type, storage_path, size, extraction_status, extraction_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + doc.Year, string(doc.Kind), doc.Filename, doc.ContentType, doc.StoragePath, + doc.Size, doc.ExtractionStatus, doc.ExtractionError, + doc.CreatedAt.Format(time.RFC3339), doc.UpdatedAt.Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("inserting employment_document: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("getting last insert id for employment_document: %w", err) + } + doc.ID = id + return nil +} + +// GetByID retrieves an employment document by its ID. +func (r *EmploymentDocumentRepository) GetByID(ctx context.Context, id int64) (*domain.EmploymentDocument, error) { + row := r.db.QueryRowContext(ctx, `SELECT `+employmentDocumentColumns+` FROM employment_documents WHERE id = ?`, id) + doc, err := scanEmploymentDocument(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("employment_document %d: %w", id, domain.ErrNotFound) + } + return nil, fmt.Errorf("querying employment_document %d: %w", id, err) + } + return doc, nil +} + +// ListByYear retrieves all employment documents for a given year, newest first. +func (r *EmploymentDocumentRepository) ListByYear(ctx context.Context, year int) ([]*domain.EmploymentDocument, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT `+employmentDocumentColumns+` FROM employment_documents WHERE year = ? ORDER BY created_at DESC`, + year, + ) + if err != nil { + return nil, fmt.Errorf("listing employment_documents for year %d: %w", year, err) + } + defer func() { _ = rows.Close() }() + + var result []*domain.EmploymentDocument + for rows.Next() { + doc, err := scanEmploymentDocument(rows) + if err != nil { + return nil, fmt.Errorf("scanning employment_document row: %w", err) + } + result = append(result, doc) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating employment_document rows: %w", err) + } + return result, nil +} + +// Delete removes an employment document by ID. +func (r *EmploymentDocumentRepository) Delete(ctx context.Context, id int64) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM employment_documents WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("deleting employment_document %d: %w", id, err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected for employment_document %d delete: %w", id, err) + } + if rows == 0 { + return fmt.Errorf("employment_document %d: %w", id, domain.ErrNotFound) + } + return nil +} + +// UpdateExtraction updates the extraction status and error for an employment document. +func (r *EmploymentDocumentRepository) UpdateExtraction(ctx context.Context, id int64, status, errMsg string) error { + now := time.Now() + + result, err := r.db.ExecContext(ctx, ` + UPDATE employment_documents SET extraction_status = ?, extraction_error = ?, updated_at = ? WHERE id = ?`, + status, errMsg, now.Format(time.RFC3339), id, + ) + if err != nil { + return fmt.Errorf("updating extraction for employment_document %d: %w", id, err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected for employment_document %d extraction update: %w", id, err) + } + if rows == 0 { + return fmt.Errorf("employment_document %d: %w", id, domain.ErrNotFound) + } + return nil +} diff --git a/internal/repository/employment_document_repo_test.go b/internal/repository/employment_document_repo_test.go new file mode 100644 index 0000000..a91f9f0 --- /dev/null +++ b/internal/repository/employment_document_repo_test.go @@ -0,0 +1,255 @@ +package repository + +import ( + "context" + "errors" + "testing" + + "github.com/zajca/zfaktury/internal/domain" + "github.com/zajca/zfaktury/internal/testutil" +) + +func seedEmploymentDocument(t *testing.T, repo *EmploymentDocumentRepository, doc *domain.EmploymentDocument) *domain.EmploymentDocument { + t.Helper() + if doc == nil { + doc = &domain.EmploymentDocument{} + } + if doc.Year == 0 { + doc.Year = 2025 + } + if doc.Filename == "" { + doc.Filename = "potvrzeni.pdf" + } + if doc.ContentType == "" { + doc.ContentType = "application/pdf" + } + if doc.StoragePath == "" { + doc.StoragePath = "/data/employment_docs/2025/potvrzeni.pdf" + } + if doc.Size == 0 { + doc.Size = 12345 + } + + ctx := context.Background() + if err := repo.Create(ctx, doc); err != nil { + t.Fatalf("seedEmploymentDocument: %v", err) + } + return doc +} + +func TestEmploymentDocumentRepository_Create(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + doc := &domain.EmploymentDocument{ + Year: 2025, + Kind: domain.EmploymentDocAdvance, + Filename: "potvrzeni-vzor33.pdf", + ContentType: "application/pdf", + StoragePath: "/data/employment_docs/2025/potvrzeni-vzor33.pdf", + Size: 54321, + } + + if err := repo.Create(ctx, doc); err != nil { + t.Fatalf("Create() error: %v", err) + } + if doc.ID == 0 { + t.Error("expected non-zero ID after Create") + } + if doc.CreatedAt.IsZero() { + t.Error("expected CreatedAt to be set") + } + if doc.UpdatedAt.IsZero() { + t.Error("expected UpdatedAt to be set") + } + if doc.ExtractionStatus != domain.ExtractionPending { + t.Errorf("default ExtractionStatus = %q, want %q", doc.ExtractionStatus, domain.ExtractionPending) + } +} + +func TestEmploymentDocumentRepository_CreateDefaultsKind(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + doc := &domain.EmploymentDocument{ + Year: 2025, + Filename: "no-kind.pdf", + ContentType: "application/pdf", + StoragePath: "/data/employment_docs/2025/no-kind.pdf", + } + if err := repo.Create(ctx, doc); err != nil { + t.Fatalf("Create() error: %v", err) + } + if doc.Kind != domain.EmploymentDocAdvance { + t.Errorf("default Kind = %q, want %q", doc.Kind, domain.EmploymentDocAdvance) + } +} + +func TestEmploymentDocumentRepository_GetByID(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + seeded := seedEmploymentDocument(t, repo, &domain.EmploymentDocument{ + Year: 2025, + Kind: domain.EmploymentDocWithholding, + Filename: "vzor12.pdf", + Size: 99999, + }) + + got, err := repo.GetByID(ctx, seeded.ID) + if err != nil { + t.Fatalf("GetByID() error: %v", err) + } + if got.Year != 2025 { + t.Errorf("Year = %d, want %d", got.Year, 2025) + } + if got.Kind != domain.EmploymentDocWithholding { + t.Errorf("Kind = %q, want %q", got.Kind, domain.EmploymentDocWithholding) + } + if got.Filename != "vzor12.pdf" { + t.Errorf("Filename = %q, want %q", got.Filename, "vzor12.pdf") + } + if got.Size != 99999 { + t.Errorf("Size = %d, want %d", got.Size, 99999) + } +} + +func TestEmploymentDocumentRepository_GetByID_NotFound(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + _, err := repo.GetByID(ctx, 99999) + if err == nil { + t.Error("expected error for non-existent document") + } + if !errors.Is(err, domain.ErrNotFound) { + t.Errorf("expected ErrNotFound, got: %v", err) + } +} + +func TestEmploymentDocumentRepository_ListByYear(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + seedEmploymentDocument(t, repo, &domain.EmploymentDocument{Year: 2025, Filename: "doc1.pdf"}) + seedEmploymentDocument(t, repo, &domain.EmploymentDocument{Year: 2025, Filename: "doc2.pdf"}) + seedEmploymentDocument(t, repo, &domain.EmploymentDocument{Year: 2024, Filename: "doc3.pdf"}) + + docs, err := repo.ListByYear(ctx, 2025) + if err != nil { + t.Fatalf("ListByYear() error: %v", err) + } + if len(docs) != 2 { + t.Errorf("len(docs) = %d, want 2", len(docs)) + } +} + +func TestEmploymentDocumentRepository_ListByYear_Empty(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + docs, err := repo.ListByYear(ctx, 2099) + if err != nil { + t.Fatalf("ListByYear() error: %v", err) + } + if len(docs) != 0 { + t.Errorf("expected empty result, got %d", len(docs)) + } +} + +func TestEmploymentDocumentRepository_Delete(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + seeded := seedEmploymentDocument(t, repo, nil) + + if err := repo.Delete(ctx, seeded.ID); err != nil { + t.Fatalf("Delete() error: %v", err) + } + + _, err := repo.GetByID(ctx, seeded.ID) + if err == nil { + t.Error("expected error when getting deleted document") + } + if !errors.Is(err, domain.ErrNotFound) { + t.Errorf("expected ErrNotFound, got: %v", err) + } +} + +func TestEmploymentDocumentRepository_Delete_NotFound(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + err := repo.Delete(ctx, 99999) + if err == nil { + t.Error("expected error for non-existent document") + } + if !errors.Is(err, domain.ErrNotFound) { + t.Errorf("expected ErrNotFound, got: %v", err) + } +} + +func TestEmploymentDocumentRepository_UpdateExtraction(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + seeded := seedEmploymentDocument(t, repo, nil) + + if err := repo.UpdateExtraction(ctx, seeded.ID, domain.ExtractionExtracted, ""); err != nil { + t.Fatalf("UpdateExtraction() error: %v", err) + } + + got, err := repo.GetByID(ctx, seeded.ID) + if err != nil { + t.Fatalf("GetByID() error: %v", err) + } + if got.ExtractionStatus != domain.ExtractionExtracted { + t.Errorf("ExtractionStatus = %q, want %q", got.ExtractionStatus, domain.ExtractionExtracted) + } +} + +func TestEmploymentDocumentRepository_UpdateExtraction_WithError(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + seeded := seedEmploymentDocument(t, repo, nil) + + if err := repo.UpdateExtraction(ctx, seeded.ID, domain.ExtractionFailed, "OCR service unavailable"); err != nil { + t.Fatalf("UpdateExtraction() error: %v", err) + } + + got, err := repo.GetByID(ctx, seeded.ID) + if err != nil { + t.Fatalf("GetByID() error: %v", err) + } + if got.ExtractionStatus != domain.ExtractionFailed { + t.Errorf("ExtractionStatus = %q, want %q", got.ExtractionStatus, domain.ExtractionFailed) + } + if got.ExtractionError != "OCR service unavailable" { + t.Errorf("ExtractionError = %q, want %q", got.ExtractionError, "OCR service unavailable") + } +} + +func TestEmploymentDocumentRepository_UpdateExtraction_NotFound(t *testing.T) { + db := testutil.NewTestDB(t) + repo := NewEmploymentDocumentRepository(db) + ctx := context.Background() + + err := repo.UpdateExtraction(ctx, 99999, domain.ExtractionExtracted, "") + if err == nil { + t.Error("expected error for non-existent document") + } + if !errors.Is(err, domain.ErrNotFound) { + t.Errorf("expected ErrNotFound, got: %v", err) + } +} diff --git a/internal/repository/income_tax_return_repo.go b/internal/repository/income_tax_return_repo.go index 5fac7a6..6443e27 100644 --- a/internal/repository/income_tax_return_repo.go +++ b/internal/repository/income_tax_return_repo.go @@ -5,11 +5,49 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" "github.com/zajca/zfaktury/internal/domain" ) +// joinWarnings serialises a warnings slice into the comma-separated form +// stored in the income_tax_returns.warnings TEXT column. nil/empty input +// produces "" (the migration default). +func joinWarnings(warnings []string) string { + if len(warnings) == 0 { + return "" + } + cleaned := make([]string, 0, len(warnings)) + for _, w := range warnings { + w = strings.TrimSpace(w) + if w != "" { + cleaned = append(cleaned, w) + } + } + return strings.Join(cleaned, ",") +} + +// splitWarnings parses the stored comma-separated warnings string into a +// slice. Empty input produces nil to keep the in-memory representation clean. +func splitWarnings(s string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return nil + } + return out +} + // IncomeTaxReturnRepository handles persistence of IncomeTaxReturn entities. type IncomeTaxReturnRepository struct { db *sql.DB @@ -30,7 +68,10 @@ const incomeTaxReturnColumns = `id, year, filing_type, capital_income_gross, capital_income_tax, capital_income_net, other_income_gross, other_income_expenses, other_income_exempt, other_income_net, deduction_mortgage, deduction_life_insurance, deduction_pension, deduction_donation, deduction_union_dues, - xml_data, status, filed_at, created_at, updated_at` + section6_gross_income, section6_income_without_advance, section6_foreign_tax, section6_tax_base, + section6_advance_withheld, section6_withholding_credited, section6_monthly_bonus_paid, + section6_certs_advance, section6_certs_withholding, section6_certs_bonus, + xml_data, warnings, status, filed_at, created_at, updated_at` // scanIncomeTaxReturn scans an IncomeTaxReturn from a row. func scanIncomeTaxReturn(s scanner) (*domain.IncomeTaxReturn, error) { @@ -38,6 +79,7 @@ func scanIncomeTaxReturn(s scanner) (*domain.IncomeTaxReturn, error) { var filedAtStr sql.NullString var createdAtStr, updatedAtStr string var xmlData []byte + var warningsStr string err := s.Scan( &itr.ID, &itr.Year, &itr.FilingType, @@ -49,7 +91,10 @@ func scanIncomeTaxReturn(s scanner) (*domain.IncomeTaxReturn, error) { &itr.CapitalIncomeGross, &itr.CapitalIncomeTax, &itr.CapitalIncomeNet, &itr.OtherIncomeGross, &itr.OtherIncomeExpenses, &itr.OtherIncomeExempt, &itr.OtherIncomeNet, &itr.DeductionMortgage, &itr.DeductionLifeInsurance, &itr.DeductionPension, &itr.DeductionDonation, &itr.DeductionUnionDues, - &xmlData, &itr.Status, &filedAtStr, + &itr.Section6GrossIncome, &itr.Section6IncomeWithoutAdvance, &itr.Section6ForeignTax, &itr.Section6TaxBase, + &itr.Section6AdvanceWithheld, &itr.Section6WithholdingCredited, &itr.Section6MonthlyBonusPaid, + &itr.Section6CertsAdvance, &itr.Section6CertsWithholding, &itr.Section6CertsBonus, + &xmlData, &warningsStr, &itr.Status, &filedAtStr, &createdAtStr, &updatedAtStr, ) if err != nil { @@ -57,6 +102,7 @@ func scanIncomeTaxReturn(s scanner) (*domain.IncomeTaxReturn, error) { } itr.XMLData = xmlData + itr.Warnings = splitWarnings(warningsStr) itr.CreatedAt, err = parseDate(time.RFC3339, createdAtStr) if err != nil { @@ -94,8 +140,11 @@ func (r *IncomeTaxReturnRepository) Create(ctx context.Context, itr *domain.Inco capital_income_gross, capital_income_tax, capital_income_net, other_income_gross, other_income_expenses, other_income_exempt, other_income_net, deduction_mortgage, deduction_life_insurance, deduction_pension, deduction_donation, deduction_union_dues, - xml_data, status, filed_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + section6_gross_income, section6_income_without_advance, section6_foreign_tax, section6_tax_base, + section6_advance_withheld, section6_withholding_credited, section6_monthly_bonus_paid, + section6_certs_advance, section6_certs_withholding, section6_certs_bonus, + xml_data, warnings, status, filed_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, itr.Year, itr.FilingType, itr.TotalRevenue, itr.ActualExpenses, itr.FlatRatePercent, itr.FlatRateAmount, itr.UsedExpenses, itr.TaxBase, itr.TotalDeductions, itr.TaxBaseRounded, itr.TaxAt15, itr.TaxAt23, itr.TotalTax, @@ -105,7 +154,10 @@ func (r *IncomeTaxReturnRepository) Create(ctx context.Context, itr *domain.Inco itr.CapitalIncomeGross, itr.CapitalIncomeTax, itr.CapitalIncomeNet, itr.OtherIncomeGross, itr.OtherIncomeExpenses, itr.OtherIncomeExempt, itr.OtherIncomeNet, itr.DeductionMortgage, itr.DeductionLifeInsurance, itr.DeductionPension, itr.DeductionDonation, itr.DeductionUnionDues, - nil, itr.Status, nil, + itr.Section6GrossIncome, itr.Section6IncomeWithoutAdvance, itr.Section6ForeignTax, itr.Section6TaxBase, + itr.Section6AdvanceWithheld, itr.Section6WithholdingCredited, itr.Section6MonthlyBonusPaid, + itr.Section6CertsAdvance, itr.Section6CertsWithholding, itr.Section6CertsBonus, + nil, joinWarnings(itr.Warnings), itr.Status, nil, itr.CreatedAt.Format(time.RFC3339), itr.UpdatedAt.Format(time.RFC3339), ) if err != nil { @@ -140,7 +192,10 @@ func (r *IncomeTaxReturnRepository) Update(ctx context.Context, itr *domain.Inco capital_income_gross = ?, capital_income_tax = ?, capital_income_net = ?, other_income_gross = ?, other_income_expenses = ?, other_income_exempt = ?, other_income_net = ?, deduction_mortgage = ?, deduction_life_insurance = ?, deduction_pension = ?, deduction_donation = ?, deduction_union_dues = ?, - xml_data = ?, status = ?, filed_at = ?, updated_at = ? + section6_gross_income = ?, section6_income_without_advance = ?, section6_foreign_tax = ?, section6_tax_base = ?, + section6_advance_withheld = ?, section6_withholding_credited = ?, section6_monthly_bonus_paid = ?, + section6_certs_advance = ?, section6_certs_withholding = ?, section6_certs_bonus = ?, + xml_data = ?, warnings = ?, status = ?, filed_at = ?, updated_at = ? WHERE id = ?`, itr.Year, itr.FilingType, itr.TotalRevenue, itr.ActualExpenses, itr.FlatRatePercent, itr.FlatRateAmount, itr.UsedExpenses, @@ -151,7 +206,10 @@ func (r *IncomeTaxReturnRepository) Update(ctx context.Context, itr *domain.Inco itr.CapitalIncomeGross, itr.CapitalIncomeTax, itr.CapitalIncomeNet, itr.OtherIncomeGross, itr.OtherIncomeExpenses, itr.OtherIncomeExempt, itr.OtherIncomeNet, itr.DeductionMortgage, itr.DeductionLifeInsurance, itr.DeductionPension, itr.DeductionDonation, itr.DeductionUnionDues, - itr.XMLData, itr.Status, filedAt, + itr.Section6GrossIncome, itr.Section6IncomeWithoutAdvance, itr.Section6ForeignTax, itr.Section6TaxBase, + itr.Section6AdvanceWithheld, itr.Section6WithholdingCredited, itr.Section6MonthlyBonusPaid, + itr.Section6CertsAdvance, itr.Section6CertsWithholding, itr.Section6CertsBonus, + itr.XMLData, joinWarnings(itr.Warnings), itr.Status, filedAt, itr.UpdatedAt.Format(time.RFC3339), itr.ID, ) if err != nil { diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index ef2564e..cf6af31 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -333,3 +333,24 @@ type TaxDeductionDocumentRepo interface { Delete(ctx context.Context, id int64) error UpdateExtraction(ctx context.Context, id int64, amount domain.Amount, confidence float64) error } + +// EmploymentDocumentRepo defines the persistence interface for §6 employment +// income Potvrzení uploads (RFC-016). +type EmploymentDocumentRepo interface { + Create(ctx context.Context, doc *domain.EmploymentDocument) error + GetByID(ctx context.Context, id int64) (*domain.EmploymentDocument, error) + ListByYear(ctx context.Context, year int) ([]*domain.EmploymentDocument, error) + Delete(ctx context.Context, id int64) error + UpdateExtraction(ctx context.Context, id int64, status, errMsg string) error +} + +// EmploymentCertificateRepo defines the persistence interface for parsed/manual +// §6 employment income certificates (RFC-016). +type EmploymentCertificateRepo interface { + Create(ctx context.Context, cert *domain.EmploymentCertificate) error + GetByID(ctx context.Context, id int64) (*domain.EmploymentCertificate, error) + Update(ctx context.Context, cert *domain.EmploymentCertificate) error + Delete(ctx context.Context, id int64) error + ListByYear(ctx context.Context, year int) ([]*domain.EmploymentCertificate, error) + ListConfirmedByYear(ctx context.Context, year int) ([]*domain.EmploymentCertificate, error) +} diff --git a/internal/server/server.go b/internal/server/server.go index 6034cce..57e58ee 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -288,8 +288,13 @@ func wireRouter(cfg *config.Config, db *sql.DB) *chi.Mux { investmentDocSvc := service.NewInvestmentDocumentService(investmentDocRepo, capitalIncomeRepo, securityTransactionRepo, cfg.DataDir, auditSvc) investmentIncomeSvc := service.NewInvestmentIncomeService(capitalIncomeRepo, securityTransactionRepo, auditSvc) + // Wire §6 employment income repos (RFC-016). + employmentDocRepo := repository.NewEmploymentDocumentRepository(db) + employmentCertRepo := repository.NewEmploymentCertificateRepository(db) + incomeTaxSvc := service.NewIncomeTaxReturnService(incomeTaxReturnRepo, invoiceRepo, expenseRepo, settingsRepo, taxYearSettingsRepo, taxPrepaymentRepo, taxCreditsSvc, auditSvc) incomeTaxSvc.SetInvestmentService(investmentIncomeSvc) + incomeTaxSvc.SetEmploymentCertificateRepo(employmentCertRepo) incomeTaxBundleSvc := service.NewIncomeTaxBundleService( incomeTaxReturnRepo, taxDeductionRepo, @@ -307,11 +312,13 @@ func wireRouter(cfg *config.Config, db *sql.DB) *chi.Mux { var ocrSvc *service.OCRService var taxExtractionSvc *service.TaxDocumentExtractionService var investmentExtractionSvc *service.InvestmentExtractionService + var ocrProvider ocr.Provider if cfg.OCR.APIKey != "" { provider, err := ocr.NewProvider(cfg.OCR.Provider, cfg.OCR.APIKey, cfg.OCR.Model, cfg.OCR.BaseURL) if err != nil { slog.Warn("OCR disabled", "error", err) } else { + ocrProvider = provider ocrSvc = service.NewOCRService(provider, documentSvc) taxExtractionSvc = service.NewTaxDocumentExtractionService(provider, taxDeductionDocSvc, taxDeductionRepo, taxDeductionDocRepo) investmentExtractionSvc = service.NewInvestmentExtractionService(provider, investmentDocSvc, capitalIncomeRepo, securityTransactionRepo, investmentDocRepo) @@ -319,6 +326,10 @@ func wireRouter(cfg *config.Config, db *sql.DB) *chi.Mux { } } + // Wire §6 employment certificate service (RFC-016). OCR provider may be + // nil — manual entry still works. + employmentSvc := service.NewEmploymentCertificateService(employmentDocRepo, employmentCertRepo, ocrProvider, auditSvc, cfg.DataDir) + // Wire import service (for upload-first expense creation). importSvc := service.NewImportService(expenseSvc, documentSvc, ocrSvc) @@ -369,7 +380,7 @@ func wireRouter(cfg *config.Config, db *sql.DB) *chi.Mux { reminderRepo := repository.NewReminderRepository(db) reminderSvc := service.NewReminderService(reminderRepo, invoiceRepo, emailSender, settingsSvc) - return handler.NewRouter(contactSvc, invoiceSvc, expenseSvc, settingsSvc, sequenceSvc, categorySvc, documentSvc, recurringInvoiceSvc, recurringExpenseSvc, ocrSvc, importSvc, overdueSvc, reminderSvc, cnbClient, pdfGen, isdocGen, vatReturnSvc, vatControlSvc, viesSvc, incomeTaxSvc, incomeTaxBundleSvc, socialInsuranceSvc, healthInsuranceSvc, taxYearSettingsSvc, taxCreditsSvc, taxDeductionDocSvc, taxExtractionSvc, investmentIncomeSvc, investmentDocSvc, investmentExtractionSvc, invDocumentSvc, fakturoidImportSvc, dashboardSvc, reportSvc, taxCalendarSvc, emailSender, auditSvc, backupSvc, handler.RouterConfig{ + return handler.NewRouter(contactSvc, invoiceSvc, expenseSvc, settingsSvc, sequenceSvc, categorySvc, documentSvc, recurringInvoiceSvc, recurringExpenseSvc, ocrSvc, importSvc, overdueSvc, reminderSvc, cnbClient, pdfGen, isdocGen, vatReturnSvc, vatControlSvc, viesSvc, incomeTaxSvc, incomeTaxBundleSvc, socialInsuranceSvc, healthInsuranceSvc, taxYearSettingsSvc, taxCreditsSvc, taxDeductionDocSvc, taxExtractionSvc, investmentIncomeSvc, investmentDocSvc, investmentExtractionSvc, employmentSvc, invDocumentSvc, fakturoidImportSvc, dashboardSvc, reportSvc, taxCalendarSvc, emailSender, auditSvc, backupSvc, handler.RouterConfig{ DevMode: cfg.Server.Dev, DataDir: cfg.DataDir, }) diff --git a/internal/service/employment_certificate_svc.go b/internal/service/employment_certificate_svc.go new file mode 100644 index 0000000..a330550 --- /dev/null +++ b/internal/service/employment_certificate_svc.go @@ -0,0 +1,589 @@ +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "github.com/zajca/zfaktury/internal/domain" + "github.com/zajca/zfaktury/internal/service/ocr" +) + +// employmentDocumentRepo is the local repository contract consumed by +// EmploymentCertificateService for Potvrzení uploads. The lead promotes this +// interface into internal/repository/interfaces.go during merge. +type employmentDocumentRepo interface { + Create(ctx context.Context, doc *domain.EmploymentDocument) error + GetByID(ctx context.Context, id int64) (*domain.EmploymentDocument, error) + ListByYear(ctx context.Context, year int) ([]*domain.EmploymentDocument, error) + Delete(ctx context.Context, id int64) error + UpdateExtraction(ctx context.Context, id int64, status, errMsg string) error +} + +// employmentCertificateRepo is the local repository contract for parsed/manual +// Potvrzení certificates. Lead promotes into interfaces.go during merge. +type employmentCertificateRepo interface { + Create(ctx context.Context, cert *domain.EmploymentCertificate) error + GetByID(ctx context.Context, id int64) (*domain.EmploymentCertificate, error) + Update(ctx context.Context, cert *domain.EmploymentCertificate) error + Delete(ctx context.Context, id int64) error + ListByYear(ctx context.Context, year int) ([]*domain.EmploymentCertificate, error) + ListConfirmedByYear(ctx context.Context, year int) ([]*domain.EmploymentCertificate, error) +} + +// employmentAllowedContentTypes is the MIME allowlist for Potvrzení uploads +// (RFC-016 §Service: PDF/JPEG/PNG/WEBP). HEIC is intentionally excluded — Czech +// employers do not issue HEIC Potvrzení. +var employmentAllowedContentTypes = map[string]bool{ + "application/pdf": true, + "image/jpeg": true, + "image/png": true, + "image/webp": true, +} + +// employmentMaxDocumentSize caps employment Potvrzení uploads at 10 MB per RFC-016 §Service. +const employmentMaxDocumentSize = 10 << 20 // 10 MB + +// employmentOCRSupportedContentTypes mirrors ocrSupportedInvestmentContentTypes — +// the OCR vision provider does not support WEBP yet, so confine extraction to +// PDF / JPEG / PNG. +var employmentOCRSupportedContentTypes = map[string]bool{ + "application/pdf": true, + "image/jpeg": true, + "image/png": true, +} + +// employmentCertificateStatusDraft / Confirmed mirror the status column. +const ( + employmentCertificateStatusDraft = "draft" + employmentCertificateStatusConfirmed = "confirmed" +) + +// EmploymentCertificateService provides business logic for Potvrzení o +// zdanitelných příjmech ze závislé činnosti (§6 ZDP) document upload, AI +// extraction, and certificate CRUD. +type EmploymentCertificateService struct { + docs employmentDocumentRepo + certs employmentCertificateRepo + ocr ocr.Provider + audit *AuditService + dataDir string +} + +// NewEmploymentCertificateService wires a new EmploymentCertificateService. +// `ocrProvider` may be nil if the deployment runs without AI (manual entry only); +// ExtractDocument will return ErrInvalidInput in that case. +func NewEmploymentCertificateService( + docs employmentDocumentRepo, + certs employmentCertificateRepo, + ocrProvider ocr.Provider, + audit *AuditService, + dataDir string, +) *EmploymentCertificateService { + return &EmploymentCertificateService{ + docs: docs, + certs: certs, + ocr: ocrProvider, + audit: audit, + dataDir: dataDir, + } +} + +// UploadDocument validates and stores a new Potvrzení document. Storage layout: +// `DataDir/employment_docs/{year}/{uuid}_{filename}`. The row is persisted with +// `extraction_status='pending'`; ExtractDocument is the next step. +func (s *EmploymentCertificateService) UploadDocument( + ctx context.Context, + year int, + kind, filename, contentType string, + content io.Reader, +) (*domain.EmploymentDocument, error) { + if year < 2000 || year > 2100 { + return nil, fmt.Errorf("year must be between 2000 and 2100, got %d: %w", year, domain.ErrInvalidInput) + } + + docKind := domain.EmploymentDocumentKind(kind) + if docKind == "" { + docKind = domain.EmploymentDocAdvance + } + switch docKind { + case domain.EmploymentDocAdvance, domain.EmploymentDocWithholding, domain.EmploymentDocBonus: + // ok + default: + return nil, fmt.Errorf("kind %q is not valid; allowed: advance, withholding, bonus: %w", kind, domain.ErrInvalidInput) + } + + if !employmentAllowedContentTypes[contentType] { + return nil, fmt.Errorf("content type %q is not allowed; allowed types: application/pdf, image/jpeg, image/png, image/webp: %w", contentType, domain.ErrInvalidInput) + } + + filename = sanitizeFilename(filename) + if filename == "" { + return nil, fmt.Errorf("filename is required: %w", domain.ErrInvalidInput) + } + + // Read file with size guard: read one extra byte to detect overflow. + limited := io.LimitReader(content, employmentMaxDocumentSize+1) + fileBytes, err := io.ReadAll(limited) + if err != nil { + return nil, fmt.Errorf("reading uploaded employment document: %w", err) + } + if int64(len(fileBytes)) > employmentMaxDocumentSize { + return nil, fmt.Errorf("file size exceeds maximum of %d MB: %w", employmentMaxDocumentSize>>20, domain.ErrInvalidInput) + } + + // Detect actual content type from file bytes to defeat MIME spoofing. + detectedType := http.DetectContentType(fileBytes) + if idx := strings.IndexByte(detectedType, ';'); idx != -1 { + detectedType = strings.TrimSpace(detectedType[:idx]) + } + if detectedType == "application/octet-stream" { + detectedType = detectByMagicBytes(fileBytes, contentType) + } + if !employmentAllowedContentTypes[detectedType] { + return nil, fmt.Errorf("detected content type %q is not allowed: %w", detectedType, domain.ErrInvalidInput) + } + contentType = detectedType + + // {dataDir}/employment_docs/{year}/{uuid}_{filename} + storageDir := filepath.Join(s.dataDir, "employment_docs", fmt.Sprintf("%d", year)) + if err := os.MkdirAll(storageDir, 0750); err != nil { + return nil, fmt.Errorf("creating employment document storage directory: %w", err) + } + + storageName := uuid.New().String() + "_" + filename + storagePath := filepath.Join(storageDir, storageName) + + if err := os.WriteFile(storagePath, fileBytes, 0640); err != nil { + return nil, fmt.Errorf("writing employment document to disk: %w", err) + } + + doc := &domain.EmploymentDocument{ + Year: year, + Kind: docKind, + Filename: filename, + ContentType: contentType, + StoragePath: storagePath, + Size: int64(len(fileBytes)), + ExtractionStatus: domain.ExtractionPending, + } + + if err := s.docs.Create(ctx, doc); err != nil { + // Clean up file if DB write fails. + _ = os.Remove(storagePath) + return nil, fmt.Errorf("saving employment document record: %w", err) + } + + if s.audit != nil { + s.audit.Log(ctx, "employment_document", doc.ID, "create", nil, map[string]any{ + "id": doc.ID, + "year": doc.Year, + "kind": string(doc.Kind), + "filename": doc.Filename, + "content_type": doc.ContentType, + }) + } + + return doc, nil +} + +// ExtractDocument runs the AI provider against an uploaded Potvrzení and +// persists a draft EmploymentCertificate. On success the document's +// extraction_status is set to "extracted"; on any failure it is set to +// "failed" with the error message recorded. +func (s *EmploymentCertificateService) ExtractDocument(ctx context.Context, docID int64) (*domain.EmploymentCertificate, error) { + if docID == 0 { + return nil, fmt.Errorf("document ID is required: %w", domain.ErrInvalidInput) + } + if s.ocr == nil { + return nil, fmt.Errorf("OCR provider is not configured: %w", domain.ErrInvalidInput) + } + + doc, err := s.docs.GetByID(ctx, docID) + if err != nil { + return nil, fmt.Errorf("fetching employment document for extraction: %w", err) + } + + if !employmentOCRSupportedContentTypes[doc.ContentType] { + return nil, fmt.Errorf("document content type %q is not supported for extraction; supported: application/pdf, image/jpeg, image/png: %w", doc.ContentType, domain.ErrInvalidInput) + } + + // Validate path is inside our data dir before reading (mirrors investment flow). + expectedPrefix := filepath.Join(s.dataDir, "employment_docs") + string(filepath.Separator) + absPath, err := filepath.EvalSymlinks(doc.StoragePath) + if err != nil { + return nil, fmt.Errorf("invalid storage path: %w", err) + } + if !strings.HasPrefix(absPath, expectedPrefix) { + return nil, fmt.Errorf("document storage path is outside allowed directory: %w", domain.ErrInvalidInput) + } + + fileData, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("reading employment document file: %w", err) + } + + rawResponse, err := s.ocr.ProcessWithPrompt( + ctx, + fileData, + doc.ContentType, + ocr.EmploymentSystemPrompt(), + ocr.EmploymentUserPrompt(), + ) + if err != nil { + _ = s.docs.UpdateExtraction(ctx, docID, domain.ExtractionFailed, err.Error()) + return nil, fmt.Errorf("AI processing failed for employment document: %w", err) + } + + parsed, err := ocr.ParseEmploymentResponse(rawResponse) + if err != nil { + _ = s.docs.UpdateExtraction(ctx, docID, domain.ExtractionFailed, err.Error()) + return nil, fmt.Errorf("parsing employment AI response: %w", err) + } + + cert, err := s.buildCertificateFromExtraction(parsed, doc) + if err != nil { + _ = s.docs.UpdateExtraction(ctx, docID, domain.ExtractionFailed, err.Error()) + return nil, fmt.Errorf("building employment certificate from extraction: %w", err) + } + + if err := s.validateCertificate(cert); err != nil { + _ = s.docs.UpdateExtraction(ctx, docID, domain.ExtractionFailed, err.Error()) + return nil, fmt.Errorf("validating extracted employment certificate: %w", err) + } + + if err := s.certs.Create(ctx, cert); err != nil { + _ = s.docs.UpdateExtraction(ctx, docID, domain.ExtractionFailed, err.Error()) + return nil, fmt.Errorf("creating employment certificate from extraction: %w", err) + } + + if err := s.docs.UpdateExtraction(ctx, docID, domain.ExtractionExtracted, ""); err != nil { + return nil, fmt.Errorf("updating employment document extraction status: %w", err) + } + + if s.audit != nil { + s.audit.Log(ctx, "employment_certificate", cert.ID, "create", nil, map[string]any{ + "id": cert.ID, + "year": cert.Year, + "document_id": cert.DocumentID, + "certificate_type": string(cert.CertificateType), + "employer_ico": cert.EmployerICO, + "source": "ocr", + }) + } + + return cert, nil +} + +// buildCertificateFromExtraction maps the OCR response into a domain certificate. +// CZK floats are converted to halere via ocr.CzkToHalere (already used by +// investment_extraction_svc.go). The year is derived from period_from / the +// document; period dates are parsed with the helper here so we can fail fast +// with ErrInvalidInput instead of a generic time.Parse error. +func (s *EmploymentCertificateService) buildCertificateFromExtraction( + parsed *ocr.EmploymentExtractionResponse, + doc *domain.EmploymentDocument, +) (*domain.EmploymentCertificate, error) { + periodFrom, err := time.Parse(time.DateOnly, parsed.PeriodFrom) + if err != nil { + return nil, fmt.Errorf("parsing period_from %q: %w", parsed.PeriodFrom, domain.ErrInvalidInput) + } + periodTo, err := time.Parse(time.DateOnly, parsed.PeriodTo) + if err != nil { + return nil, fmt.Errorf("parsing period_to %q: %w", parsed.PeriodTo, domain.ErrInvalidInput) + } + + certType := domain.CertificateType(parsed.CertificateType) + switch certType { + case domain.CertificateAdvance, domain.CertificateWithholding: + // ok + default: + // Default to advance for unknown values rather than rejecting outright; + // the user can edit before confirming. + certType = domain.CertificateAdvance + } + + contractType := domain.ContractType(parsed.ContractType) + switch contractType { + case domain.ContractDPC, domain.ContractDPP, domain.ContractHPP, domain.ContractOther: + // ok + default: + contractType = domain.ContractOther + } + + docID := doc.ID + cert := &domain.EmploymentCertificate{ + Year: doc.Year, + DocumentID: &docID, + CertificateType: certType, + EmployerName: parsed.EmployerName, + EmployerICO: parsed.EmployerICO, + EmployerAddress: parsed.EmployerAddress, + ContractType: contractType, + PeriodFrom: periodFrom, + PeriodTo: periodTo, + GrossIncome: domain.Amount(ocr.CzkToHalere(parsed.GrossIncomeCZK)), + IncomeWithoutAdvance: domain.Amount(ocr.CzkToHalere(parsed.IncomeWithoutAdvanceCZK)), + ForeignTaxPaid: domain.Amount(ocr.CzkToHalere(parsed.ForeignTaxPaidCZK)), + AdvanceTaxWithheld: domain.Amount(ocr.CzkToHalere(parsed.AdvanceTaxWithheldCZK)), + AnnualSettlementRefund: domain.Amount(ocr.CzkToHalere(parsed.AnnualSettlementRefundCZK)), + MonthlyBonusPaid: domain.Amount(ocr.CzkToHalere(parsed.MonthlyBonusPaidCZK)), + WithheldFinalTax: domain.Amount(ocr.CzkToHalere(parsed.WithheldFinalTaxCZK)), + IncludeWithholdingInDAP: false, + Status: employmentCertificateStatusDraft, + } + return cert, nil +} + +// Create validates and persists a manually-entered certificate. +func (s *EmploymentCertificateService) Create(ctx context.Context, cert *domain.EmploymentCertificate) error { + if cert == nil { + return fmt.Errorf("certificate is required: %w", domain.ErrInvalidInput) + } + if cert.Status == "" { + cert.Status = employmentCertificateStatusDraft + } + if err := s.validateCertificate(cert); err != nil { + return err + } + if err := s.certs.Create(ctx, cert); err != nil { + return fmt.Errorf("creating employment certificate: %w", err) + } + if s.audit != nil { + s.audit.Log(ctx, "employment_certificate", cert.ID, "create", nil, map[string]any{ + "id": cert.ID, + "year": cert.Year, + "certificate_type": string(cert.CertificateType), + "employer_ico": cert.EmployerICO, + "source": "manual", + }) + } + return nil +} + +// Update modifies an existing draft certificate. Confirmed certificates are +// locked — caller must Delete and re-Create for any change. +func (s *EmploymentCertificateService) Update(ctx context.Context, cert *domain.EmploymentCertificate) error { + if cert == nil || cert.ID == 0 { + return fmt.Errorf("certificate ID is required: %w", domain.ErrInvalidInput) + } + existing, err := s.certs.GetByID(ctx, cert.ID) + if err != nil { + return fmt.Errorf("fetching employment certificate for update: %w", err) + } + if existing.Status != employmentCertificateStatusDraft { + return fmt.Errorf("only draft certificates can be updated, status=%q: %w", existing.Status, domain.ErrInvalidInput) + } + + // Preserve immutable fields. + cert.CreatedAt = existing.CreatedAt + if cert.Status == "" { + cert.Status = existing.Status + } + if err := s.validateCertificate(cert); err != nil { + return err + } + if err := s.certs.Update(ctx, cert); err != nil { + return fmt.Errorf("updating employment certificate: %w", err) + } + if s.audit != nil { + s.audit.Log(ctx, "employment_certificate", cert.ID, "update", existing, cert) + } + return nil +} + +// Confirm flips status from draft to confirmed. Confirmed certificates feed the +// §6 aggregation in IncomeTaxReturnService.Recalculate. +func (s *EmploymentCertificateService) Confirm(ctx context.Context, certID int64) error { + if certID == 0 { + return fmt.Errorf("certificate ID is required: %w", domain.ErrInvalidInput) + } + cert, err := s.certs.GetByID(ctx, certID) + if err != nil { + return fmt.Errorf("fetching employment certificate for confirm: %w", err) + } + if cert.Status == employmentCertificateStatusConfirmed { + return nil + } + cert.Status = employmentCertificateStatusConfirmed + if err := s.certs.Update(ctx, cert); err != nil { + return fmt.Errorf("confirming employment certificate: %w", err) + } + if s.audit != nil { + s.audit.Log(ctx, "employment_certificate", certID, "confirm", nil, map[string]any{ + "status": cert.Status, + }) + } + return nil +} + +// ListByYear returns all non-deleted certificates for a year. +func (s *EmploymentCertificateService) ListByYear(ctx context.Context, year int) ([]*domain.EmploymentCertificate, error) { + if year < 2000 || year > 2100 { + return nil, fmt.Errorf("year must be between 2000 and 2100, got %d: %w", year, domain.ErrInvalidInput) + } + certs, err := s.certs.ListByYear(ctx, year) + if err != nil { + return nil, fmt.Errorf("listing employment certificates: %w", err) + } + return certs, nil +} + +// Get returns a single certificate by ID. +func (s *EmploymentCertificateService) Get(ctx context.Context, certID int64) (*domain.EmploymentCertificate, error) { + if certID == 0 { + return nil, fmt.Errorf("certificate ID is required: %w", domain.ErrInvalidInput) + } + cert, err := s.certs.GetByID(ctx, certID) + if err != nil { + return nil, fmt.Errorf("fetching employment certificate: %w", err) + } + return cert, nil +} + +// Delete soft-deletes a certificate. +func (s *EmploymentCertificateService) Delete(ctx context.Context, certID int64) error { + if certID == 0 { + return fmt.Errorf("certificate ID is required: %w", domain.ErrInvalidInput) + } + if err := s.certs.Delete(ctx, certID); err != nil { + return fmt.Errorf("deleting employment certificate: %w", err) + } + if s.audit != nil { + s.audit.Log(ctx, "employment_certificate", certID, "delete", nil, nil) + } + return nil +} + +// ListDocumentsByYear returns all uploaded employment documents for a year. +func (s *EmploymentCertificateService) ListDocumentsByYear(ctx context.Context, year int) ([]*domain.EmploymentDocument, error) { + if year < 2000 || year > 2100 { + return nil, fmt.Errorf("year must be between 2000 and 2100, got %d: %w", year, domain.ErrInvalidInput) + } + docs, err := s.docs.ListByYear(ctx, year) + if err != nil { + return nil, fmt.Errorf("listing employment documents: %w", err) + } + return docs, nil +} + +// GetDocument returns a single employment document by ID. +func (s *EmploymentCertificateService) GetDocument(ctx context.Context, docID int64) (*domain.EmploymentDocument, error) { + if docID == 0 { + return nil, fmt.Errorf("document ID is required: %w", domain.ErrInvalidInput) + } + doc, err := s.docs.GetByID(ctx, docID) + if err != nil { + return nil, fmt.Errorf("fetching employment document: %w", err) + } + return doc, nil +} + +// DeleteDocument removes the document row and best-effort removes the file from disk. +// Linked certificates have their document_id set to NULL via FK ON DELETE SET NULL. +func (s *EmploymentCertificateService) DeleteDocument(ctx context.Context, docID int64) error { + if docID == 0 { + return fmt.Errorf("document ID is required: %w", domain.ErrInvalidInput) + } + doc, err := s.docs.GetByID(ctx, docID) + if err != nil { + return fmt.Errorf("fetching employment document for delete: %w", err) + } + if err := s.docs.Delete(ctx, docID); err != nil { + return fmt.Errorf("deleting employment document record: %w", err) + } + if doc.StoragePath != "" { + _ = os.Remove(doc.StoragePath) + } + if s.audit != nil { + s.audit.Log(ctx, "employment_document", docID, "delete", nil, nil) + } + return nil +} + +// GetDocumentFilePath returns the on-disk path and content type for serving a +// document, validating the storage path stays inside the configured data dir +// to defeat path traversal / symlink escapes (mirror of investment flow). +func (s *EmploymentCertificateService) GetDocumentFilePath(ctx context.Context, docID int64) (string, string, error) { + if docID == 0 { + return "", "", fmt.Errorf("document ID is required: %w", domain.ErrInvalidInput) + } + doc, err := s.docs.GetByID(ctx, docID) + if err != nil { + return "", "", fmt.Errorf("fetching employment document for file path: %w", err) + } + expectedPrefix := filepath.Join(s.dataDir, "employment_docs") + string(filepath.Separator) + absPath, err := filepath.EvalSymlinks(doc.StoragePath) + if err != nil { + return "", "", fmt.Errorf("invalid storage path: %w", err) + } + if !strings.HasPrefix(absPath, expectedPrefix) { + return "", "", fmt.Errorf("document storage path is outside allowed directory: %w", domain.ErrInvalidInput) + } + return absPath, doc.ContentType, nil +} + +// validateCertificate enforces the rules from RFC-016 §Service. +func (s *EmploymentCertificateService) validateCertificate(cert *domain.EmploymentCertificate) error { + if cert.Year < 2000 || cert.Year > 2100 { + return fmt.Errorf("year must be between 2000 and 2100, got %d: %w", cert.Year, domain.ErrInvalidInput) + } + if strings.TrimSpace(cert.EmployerName) == "" { + return fmt.Errorf("employer name is required: %w", domain.ErrInvalidInput) + } + if cert.EmployerICO != "" { + if err := domain.ValidateICO(cert.EmployerICO); err != nil { + return fmt.Errorf("employer IČO is invalid: %w", err) + } + } + switch cert.CertificateType { + case domain.CertificateAdvance, domain.CertificateWithholding: + // ok + default: + return fmt.Errorf("certificate_type must be advance or withholding, got %q: %w", cert.CertificateType, domain.ErrInvalidInput) + } + switch cert.ContractType { + case domain.ContractDPC, domain.ContractDPP, domain.ContractHPP, domain.ContractOther, "": + // ok ("" gets defaulted by repo) + default: + return fmt.Errorf("contract_type must be dpc/dpp/hpp/other, got %q: %w", cert.ContractType, domain.ErrInvalidInput) + } + if cert.PeriodFrom.IsZero() || cert.PeriodTo.IsZero() { + return fmt.Errorf("period_from and period_to are required: %w", domain.ErrInvalidInput) + } + if cert.PeriodFrom.After(cert.PeriodTo) { + return fmt.Errorf("period_from must be on or before period_to: %w", domain.ErrInvalidInput) + } + if cert.PeriodFrom.Year() != cert.Year || cert.PeriodTo.Year() != cert.Year { + return fmt.Errorf("period_from and period_to must fall within year %d: %w", cert.Year, domain.ErrInvalidInput) + } + for name, amt := range map[string]domain.Amount{ + "gross_income": cert.GrossIncome, + "income_without_advance": cert.IncomeWithoutAdvance, + "foreign_tax_paid": cert.ForeignTaxPaid, + "advance_tax_withheld": cert.AdvanceTaxWithheld, + "annual_settlement_refund": cert.AnnualSettlementRefund, + "monthly_bonus_paid": cert.MonthlyBonusPaid, + "withheld_final_tax": cert.WithheldFinalTax, + } { + if amt < 0 { + return fmt.Errorf("%s must be non-negative, got %d: %w", name, amt, domain.ErrInvalidInput) + } + } + if cert.WithheldFinalTax > 0 && cert.CertificateType != domain.CertificateWithholding { + return fmt.Errorf("withheld_final_tax > 0 only allowed for withholding certificates: %w", domain.ErrInvalidInput) + } + if cert.IncludeWithholdingInDAP && cert.CertificateType != domain.CertificateWithholding { + return fmt.Errorf("include_withholding_in_dap=true only allowed for withholding certificates: %w", domain.ErrInvalidInput) + } + if cert.AnnualSettlementRefund > cert.AdvanceTaxWithheld { + return fmt.Errorf("annual_settlement_refund cannot exceed advance_tax_withheld: %w", domain.ErrInvalidInput) + } + return nil +} diff --git a/internal/service/employment_certificate_svc_test.go b/internal/service/employment_certificate_svc_test.go new file mode 100644 index 0000000..dc7a696 --- /dev/null +++ b/internal/service/employment_certificate_svc_test.go @@ -0,0 +1,574 @@ +package service + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/zajca/zfaktury/internal/domain" + "github.com/zajca/zfaktury/internal/repository" + "github.com/zajca/zfaktury/internal/service/ocr" + "github.com/zajca/zfaktury/internal/testutil" +) + +// employmentMockOCRProvider implements ocr.Provider for §6 extraction tests. +type employmentMockOCRProvider struct { + promptResponse string + promptErr error +} + +func (m *employmentMockOCRProvider) ProcessImage(_ context.Context, _ []byte, _ string) (*domain.OCRResult, error) { + return nil, nil +} + +func (m *employmentMockOCRProvider) ProcessWithPrompt(_ context.Context, _ []byte, _ string, _, _ string) (string, error) { + return m.promptResponse, m.promptErr +} + +func (m *employmentMockOCRProvider) Name() string { + return "employment-mock" +} + +var _ ocr.Provider = (*employmentMockOCRProvider)(nil) + +// newEmploymentCertSvc wires the service against real SQLite for tests. +func newEmploymentCertSvc(t *testing.T, provider ocr.Provider) ( + *EmploymentCertificateService, + *repository.EmploymentDocumentRepository, + *repository.EmploymentCertificateRepository, + string, +) { + t.Helper() + db := testutil.NewTestDB(t) + docRepo := repository.NewEmploymentDocumentRepository(db) + certRepo := repository.NewEmploymentCertificateRepository(db) + dataDir := t.TempDir() + svc := NewEmploymentCertificateService(docRepo, certRepo, provider, nil, dataDir) + return svc, docRepo, certRepo, dataDir +} + +const mockEmploymentAdvanceJSON = `{ + "certificate_type": "advance", + "employer_name": "Acme s.r.o.", + "employer_ico": "27082440", + "employer_address": "Praha 1", + "contract_type": "dpc", + "period_from": "2025-01-01", + "period_to": "2025-12-31", + "gross_income_czk": 120000.0, + "income_without_advance_czk": 0.0, + "foreign_tax_paid_czk": 0.0, + "advance_tax_withheld_czk": 18000.0, + "annual_settlement_refund_czk": 0.0, + "monthly_bonus_paid_czk": 0.0, + "withheld_final_tax_czk": 0.0, + "confidence": 0.95, + "raw_text": "Potvrzeni..." +}` + +// validCert returns a baseline confirmed-ready advance certificate. +func validCert() *domain.EmploymentCertificate { + return &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + ContractType: domain.ContractDPC, + EmployerName: "Acme s.r.o.", + EmployerICO: "27082440", + EmployerAddress: "Praha 1", + PeriodFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + GrossIncome: domain.NewAmount(120_000, 0), + AdvanceTaxWithheld: domain.NewAmount(18_000, 0), + IncludeWithholdingInDAP: false, + Status: employmentCertificateStatusDraft, + } +} + +func TestEmploymentCertSvc_UploadDocument_HappyPath(t *testing.T) { + svc, docRepo, _, dataDir := newEmploymentCertSvc(t, nil) + ctx := context.Background() + + doc, err := svc.UploadDocument(ctx, 2025, "advance", "potv.pdf", "application/pdf", bytes.NewReader(pdfMagic)) + if err != nil { + t.Fatalf("UploadDocument: %v", err) + } + if doc.ID == 0 { + t.Error("expected non-zero document ID") + } + if doc.Year != 2025 { + t.Errorf("Year = %d, want 2025", doc.Year) + } + if doc.Kind != domain.EmploymentDocAdvance { + t.Errorf("Kind = %q, want %q", doc.Kind, domain.EmploymentDocAdvance) + } + if doc.ExtractionStatus != domain.ExtractionPending { + t.Errorf("ExtractionStatus = %q, want pending", doc.ExtractionStatus) + } + // File written under {dataDir}/employment_docs/{year}/... + expectedPrefix := filepath.Join(dataDir, "employment_docs", "2025") + string(filepath.Separator) + if !strings.HasPrefix(doc.StoragePath, expectedPrefix) { + t.Errorf("StoragePath = %q does not start with %q", doc.StoragePath, expectedPrefix) + } + // Reload to verify persistence. + got, err := docRepo.GetByID(ctx, doc.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Filename != "potv.pdf" { + t.Errorf("Filename = %q, want potv.pdf", got.Filename) + } +} + +func TestEmploymentCertSvc_UploadDocument_DefaultsKindToAdvance(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + doc, err := svc.UploadDocument(context.Background(), 2025, "", "p.pdf", "application/pdf", bytes.NewReader(pdfMagic)) + if err != nil { + t.Fatalf("UploadDocument: %v", err) + } + if doc.Kind != domain.EmploymentDocAdvance { + t.Errorf("Kind = %q, want %q", doc.Kind, domain.EmploymentDocAdvance) + } +} + +func TestEmploymentCertSvc_UploadDocument_RejectsTextPlain(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + _, err := svc.UploadDocument(context.Background(), 2025, "advance", "fake.pdf", "text/plain", bytes.NewReader([]byte("not a pdf"))) + if err == nil { + t.Fatal("expected error for text/plain content type") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_UploadDocument_RejectsOversizedFile(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + oversized := bytes.NewReader(bytes.Repeat([]byte("a"), employmentMaxDocumentSize+1)) + _, err := svc.UploadDocument(context.Background(), 2025, "advance", "big.pdf", "application/pdf", oversized) + if err == nil { + t.Fatal("expected error for oversized file") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_UploadDocument_RejectsInvalidYear(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + _, err := svc.UploadDocument(context.Background(), 1999, "advance", "p.pdf", "application/pdf", bytes.NewReader(pdfMagic)) + if err == nil { + t.Fatal("expected error for invalid year") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_UploadDocument_RejectsInvalidKind(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + _, err := svc.UploadDocument(context.Background(), 2025, "garbage", "p.pdf", "application/pdf", bytes.NewReader(pdfMagic)) + if err == nil { + t.Fatal("expected error for invalid kind") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_ExtractDocument_HappyPath(t *testing.T) { + provider := &employmentMockOCRProvider{promptResponse: mockEmploymentAdvanceJSON} + svc, docRepo, certRepo, _ := newEmploymentCertSvc(t, provider) + ctx := context.Background() + + doc, err := svc.UploadDocument(ctx, 2025, "advance", "p.pdf", "application/pdf", bytes.NewReader(pdfMagic)) + if err != nil { + t.Fatalf("UploadDocument: %v", err) + } + + cert, err := svc.ExtractDocument(ctx, doc.ID) + if err != nil { + t.Fatalf("ExtractDocument: %v", err) + } + if cert.ID == 0 { + t.Error("expected non-zero cert ID") + } + if cert.Status != employmentCertificateStatusDraft { + t.Errorf("Status = %q, want draft", cert.Status) + } + if cert.CertificateType != domain.CertificateAdvance { + t.Errorf("CertificateType = %q, want advance", cert.CertificateType) + } + if cert.GrossIncome != domain.NewAmount(120_000, 0) { + t.Errorf("GrossIncome = %d, want %d", cert.GrossIncome, domain.NewAmount(120_000, 0)) + } + if cert.AdvanceTaxWithheld != domain.NewAmount(18_000, 0) { + t.Errorf("AdvanceTaxWithheld = %d, want %d", cert.AdvanceTaxWithheld, domain.NewAmount(18_000, 0)) + } + if cert.DocumentID == nil || *cert.DocumentID != doc.ID { + t.Errorf("DocumentID = %v, want %d", cert.DocumentID, doc.ID) + } + + // Document extraction status should now be "extracted". + updatedDoc, err := docRepo.GetByID(ctx, doc.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if updatedDoc.ExtractionStatus != domain.ExtractionExtracted { + t.Errorf("ExtractionStatus = %q, want extracted", updatedDoc.ExtractionStatus) + } + + // Cert is persisted. + saved, err := certRepo.GetByID(ctx, cert.ID) + if err != nil { + t.Fatalf("certRepo.GetByID: %v", err) + } + if saved.EmployerName != "Acme s.r.o." { + t.Errorf("EmployerName = %q, want Acme s.r.o.", saved.EmployerName) + } +} + +func TestEmploymentCertSvc_ExtractDocument_ProviderError_MarksFailed(t *testing.T) { + provider := &employmentMockOCRProvider{promptErr: errors.New("AI offline")} + svc, docRepo, _, _ := newEmploymentCertSvc(t, provider) + ctx := context.Background() + + doc, err := svc.UploadDocument(ctx, 2025, "advance", "p.pdf", "application/pdf", bytes.NewReader(pdfMagic)) + if err != nil { + t.Fatalf("UploadDocument: %v", err) + } + if _, err := svc.ExtractDocument(ctx, doc.ID); err == nil { + t.Fatal("expected error from provider") + } + updatedDoc, err := docRepo.GetByID(ctx, doc.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if updatedDoc.ExtractionStatus != domain.ExtractionFailed { + t.Errorf("ExtractionStatus = %q, want failed", updatedDoc.ExtractionStatus) + } + if updatedDoc.ExtractionError == "" { + t.Error("expected ExtractionError to be set") + } +} + +func TestEmploymentCertSvc_ExtractDocument_InvalidJSON_MarksFailed(t *testing.T) { + provider := &employmentMockOCRProvider{promptResponse: "not json"} + svc, docRepo, certRepo, _ := newEmploymentCertSvc(t, provider) + ctx := context.Background() + + doc, err := svc.UploadDocument(ctx, 2025, "advance", "p.pdf", "application/pdf", bytes.NewReader(pdfMagic)) + if err != nil { + t.Fatalf("UploadDocument: %v", err) + } + if _, err := svc.ExtractDocument(ctx, doc.ID); err == nil { + t.Fatal("expected error parsing invalid JSON") + } + updatedDoc, err := docRepo.GetByID(ctx, doc.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if updatedDoc.ExtractionStatus != domain.ExtractionFailed { + t.Errorf("ExtractionStatus = %q, want failed", updatedDoc.ExtractionStatus) + } + // No certificate should have been created. + certs, err := certRepo.ListByYear(ctx, 2025) + if err != nil { + t.Fatalf("ListByYear: %v", err) + } + if len(certs) != 0 { + t.Errorf("ListByYear returned %d certs, want 0 (none should be persisted on failure)", len(certs)) + } +} + +func TestEmploymentCertSvc_ExtractDocument_NoOCRProvider(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + ctx := context.Background() + doc, err := svc.UploadDocument(ctx, 2025, "advance", "p.pdf", "application/pdf", bytes.NewReader(pdfMagic)) + if err != nil { + t.Fatalf("UploadDocument: %v", err) + } + _, err = svc.ExtractDocument(ctx, doc.ID) + if err == nil { + t.Fatal("expected error when provider is nil") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_Create_HappyPath(t *testing.T) { + svc, _, certRepo, _ := newEmploymentCertSvc(t, nil) + ctx := context.Background() + + cert := validCert() + if err := svc.Create(ctx, cert); err != nil { + t.Fatalf("Create: %v", err) + } + if cert.ID == 0 { + t.Error("expected non-zero ID after Create") + } + got, err := certRepo.GetByID(ctx, cert.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.EmployerName != "Acme s.r.o." { + t.Errorf("EmployerName = %q, want Acme s.r.o.", got.EmployerName) + } +} + +func TestEmploymentCertSvc_Create_RejectsBadPeriod(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + + cert := validCert() + cert.PeriodFrom = time.Date(2025, 6, 30, 0, 0, 0, 0, time.UTC) + cert.PeriodTo = time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + err := svc.Create(context.Background(), cert) + if err == nil { + t.Fatal("expected error: period_from > period_to") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_Create_RejectsPeriodOutsideYear(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + + cert := validCert() + cert.PeriodFrom = time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC) + err := svc.Create(context.Background(), cert) + if err == nil { + t.Fatal("expected error: period outside year") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_Create_RejectsBadICO(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + + cert := validCert() + cert.EmployerICO = "ABC123" + err := svc.Create(context.Background(), cert) + if err == nil { + t.Fatal("expected error: bad ICO") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } + + cert2 := validCert() + cert2.EmployerICO = "1234567" // 7 digits, not 8 + if err := svc.Create(context.Background(), cert2); !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput for 7-digit ICO, got %v", err) + } +} + +func TestEmploymentCertSvc_Create_RejectsRefundOverWithheld(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + + cert := validCert() + cert.AdvanceTaxWithheld = domain.NewAmount(1000, 0) + cert.AnnualSettlementRefund = domain.NewAmount(2000, 0) + err := svc.Create(context.Background(), cert) + if err == nil { + t.Fatal("expected error: refund > withheld") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_Create_RejectsWithholdingFinalTaxOnAdvance(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + + cert := validCert() + cert.CertificateType = domain.CertificateAdvance + cert.WithheldFinalTax = domain.NewAmount(500, 0) + err := svc.Create(context.Background(), cert) + if err == nil { + t.Fatal("expected error: WithheldFinalTax > 0 on advance cert") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_Create_RejectsIncludeWithholdingOnAdvance(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + + cert := validCert() + cert.CertificateType = domain.CertificateAdvance + cert.IncludeWithholdingInDAP = true + err := svc.Create(context.Background(), cert) + if err == nil { + t.Fatal("expected error: include_withholding_in_dap on advance cert") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_Create_RejectsNegativeAmount(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + + cert := validCert() + cert.GrossIncome = domain.Amount(-1) + err := svc.Create(context.Background(), cert) + if err == nil { + t.Fatal("expected error: negative amount") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_Create_AcceptsEmptyICO(t *testing.T) { + // Empty ICO is allowed (e.g. zahraniční zaměstnavatel without IČO). + svc, _, _, _ := newEmploymentCertSvc(t, nil) + cert := validCert() + cert.EmployerICO = "" + if err := svc.Create(context.Background(), cert); err != nil { + t.Fatalf("Create with empty ICO should succeed, got %v", err) + } +} + +func TestEmploymentCertSvc_Update_OnlyDraft(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + ctx := context.Background() + + cert := validCert() + if err := svc.Create(ctx, cert); err != nil { + t.Fatalf("Create: %v", err) + } + + // Update while draft: ok. + cert.GrossIncome = domain.NewAmount(150_000, 0) + if err := svc.Update(ctx, cert); err != nil { + t.Fatalf("Update: %v", err) + } + + // Confirm. + if err := svc.Confirm(ctx, cert.ID); err != nil { + t.Fatalf("Confirm: %v", err) + } + + // Update after confirm: rejected. + confirmed, err := svc.Get(ctx, cert.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + confirmed.GrossIncome = domain.NewAmount(200_000, 0) + err = svc.Update(ctx, confirmed) + if err == nil { + t.Fatal("expected error: cannot update confirmed cert") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got %v", err) + } +} + +func TestEmploymentCertSvc_Confirm_FlipsStatus(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + ctx := context.Background() + + cert := validCert() + if err := svc.Create(ctx, cert); err != nil { + t.Fatalf("Create: %v", err) + } + if err := svc.Confirm(ctx, cert.ID); err != nil { + t.Fatalf("Confirm: %v", err) + } + got, err := svc.Get(ctx, cert.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Status != employmentCertificateStatusConfirmed { + t.Errorf("Status = %q, want confirmed", got.Status) + } +} + +func TestEmploymentCertSvc_Delete_SoftDeletes(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + ctx := context.Background() + + cert := validCert() + if err := svc.Create(ctx, cert); err != nil { + t.Fatalf("Create: %v", err) + } + if err := svc.Delete(ctx, cert.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + _, err := svc.Get(ctx, cert.ID) + if err == nil { + t.Error("expected error fetching soft-deleted cert") + } +} + +func TestEmploymentCertSvc_ListByYear(t *testing.T) { + svc, _, _, _ := newEmploymentCertSvc(t, nil) + ctx := context.Background() + + // Use mod-11 valid IČOs so ValidateICO passes. + icos := []string{"27082440", "26168685", "45274649"} + for i := 0; i < 3; i++ { + cert := validCert() + cert.EmployerICO = icos[i] + if err := svc.Create(ctx, cert); err != nil { + t.Fatalf("Create: %v", err) + } + } + list, err := svc.ListByYear(ctx, 2025) + if err != nil { + t.Fatalf("ListByYear: %v", err) + } + if len(list) != 3 { + t.Errorf("ListByYear returned %d, want 3", len(list)) + } +} + +// TestEmploymentCertSvc_AuditEmitsCorrectCategory verifies the service emits +// audit events with the exact category strings the lead must register. +func TestEmploymentCertSvc_AuditEmitsCorrectCategory(t *testing.T) { + db := testutil.NewTestDB(t) + docRepo := repository.NewEmploymentDocumentRepository(db) + certRepo := repository.NewEmploymentCertificateRepository(db) + auditRepo := repository.NewAuditLogRepository(db) + auditSvc := NewAuditService(auditRepo) + dataDir := t.TempDir() + svc := NewEmploymentCertificateService(docRepo, certRepo, nil, auditSvc, dataDir) + ctx := context.Background() + + doc, err := svc.UploadDocument(ctx, 2025, "advance", "p.pdf", "application/pdf", bytes.NewReader(pdfMagic)) + if err != nil { + t.Fatalf("UploadDocument: %v", err) + } + cert := validCert() + if err := svc.Create(ctx, cert); err != nil { + t.Fatalf("Create: %v", err) + } + if err := svc.Confirm(ctx, cert.ID); err != nil { + t.Fatalf("Confirm: %v", err) + } + + docEntries, err := auditSvc.ListByEntity(ctx, "employment_document", doc.ID) + if err != nil { + t.Fatalf("ListByEntity employment_document: %v", err) + } + if len(docEntries) == 0 { + t.Error("expected at least one employment_document audit entry") + } + + certEntries, err := auditSvc.ListByEntity(ctx, "employment_certificate", cert.ID) + if err != nil { + t.Fatalf("ListByEntity employment_certificate: %v", err) + } + if len(certEntries) < 2 { + t.Errorf("expected at least 2 employment_certificate audit entries (create+confirm), got %d", len(certEntries)) + } +} diff --git a/internal/service/income_tax_return_svc.go b/internal/service/income_tax_return_svc.go index e5dd33d..92839bc 100644 --- a/internal/service/income_tax_return_svc.go +++ b/internal/service/income_tax_return_svc.go @@ -21,7 +21,8 @@ type IncomeTaxReturnService struct { taxYearSettingsRepo repository.TaxYearSettingsRepo taxPrepaymentRepo repository.TaxPrepaymentRepo taxCreditsSvc *TaxCreditsService - investmentSvc *InvestmentIncomeService // nullable + investmentSvc *InvestmentIncomeService // nullable + employmentCerts employmentCertificateRepo // nullable; §6 aggregation audit *AuditService } @@ -53,6 +54,13 @@ func (s *IncomeTaxReturnService) SetInvestmentService(investmentSvc *InvestmentI s.investmentSvc = investmentSvc } +// SetEmploymentCertificateRepo wires the §6 employment certificate repository +// used during Recalculate to aggregate confirmed Potvrzení into ř.31 / ř.33 / +// ř.34 / ř.84 / ř.87 / ř.89 totals. Optional — when nil, §6 fields stay zero. +func (s *IncomeTaxReturnService) SetEmploymentCertificateRepo(repo employmentCertificateRepo) { + s.employmentCerts = repo +} + // Create validates and persists a new income tax return. func (s *IncomeTaxReturnService) Create(ctx context.Context, itr *domain.IncomeTaxReturn) error { if itr.Year < 2000 || itr.Year > 2100 { @@ -191,6 +199,48 @@ func (s *IncomeTaxReturnService) Recalculate(ctx context.Context, id int64) (*do itr.OtherIncomeNet = summary.OtherIncomeNet } + // Step 4c: §6 employment income aggregation (RFC-016). + // Only confirmed certificates feed the totals. Section6CertsBonus stays 0 + // in MVP — counts of standalone "Potvrzení o vyplaceném daňovém bonusu" + // uploads (EmploymentDocBonus kind) are out of scope. + itr.Section6GrossIncome = 0 + itr.Section6IncomeWithoutAdvance = 0 + itr.Section6ForeignTax = 0 + itr.Section6AdvanceWithheld = 0 + itr.Section6WithholdingCredited = 0 + itr.Section6MonthlyBonusPaid = 0 + itr.Section6CertsAdvance = 0 + itr.Section6CertsWithholding = 0 + itr.Section6CertsBonus = 0 + if s.employmentCerts != nil { + certs, certsErr := s.employmentCerts.ListConfirmedByYear(ctx, itr.Year) + if certsErr != nil { + return nil, fmt.Errorf("listing employment certificates for income_tax_return: %w", certsErr) + } + for _, c := range certs { + switch c.CertificateType { + case domain.CertificateAdvance: + itr.Section6GrossIncome += c.GrossIncome + itr.Section6IncomeWithoutAdvance += c.IncomeWithoutAdvance + itr.Section6ForeignTax += c.ForeignTaxPaid + itr.Section6AdvanceWithheld += c.AdvanceTaxWithheld - c.AnnualSettlementRefund + itr.Section6MonthlyBonusPaid += c.MonthlyBonusPaid + itr.Section6CertsAdvance++ + // Section6CertsBonus is the count of separate + // "Potvrzení o vyplaceném daňovém bonusu" forms (potv_dazvyh) + // — NOT advance certs that paid a bonus. Stays 0 in MVP. + case domain.CertificateWithholding: + if c.IncludeWithholdingInDAP { + itr.Section6GrossIncome += c.GrossIncome + itr.Section6WithholdingCredited += c.WithheldFinalTax + itr.Section6CertsWithholding++ + } + } + } + } + // Dílčí ZD §6 (ř.34/36) = ř.31 - ř.33. + itr.Section6TaxBase = itr.Section6GrossIncome - itr.Section6ForeignTax + // Compute credits and deductions from DB (needed as calc inputs). var spouseCredit, disabilityCredit, studentCredit, childBenefit, totalDeductions domain.Amount if s.taxCreditsSvc != nil { @@ -203,8 +253,14 @@ func (s *IncomeTaxReturnService) Recalculate(ctx context.Context, id int64) (*do if credErr != nil { return nil, fmt.Errorf("computing child benefit for income_tax_return: %w", credErr) } - // Compute raw tax base for deduction cap calculation. - rawBase := itr.TotalRevenue - calc.ResolveUsedExpenses(itr.TotalRevenue, itr.ActualExpenses, flatRatePercent, constants.FlatRateCaps) + itr.CapitalIncomeNet + itr.OtherIncomeNet + // Compute raw tax base for deduction cap calculation. Mirror the calc + // package's "drop §7+§8+§10 if negative" guard so deduction caps that + // reference tax base do not blow up when business expenses exceed revenue. + otherSectionsBase := itr.TotalRevenue - calc.ResolveUsedExpenses(itr.TotalRevenue, itr.ActualExpenses, flatRatePercent, constants.FlatRateCaps) + itr.CapitalIncomeNet + itr.OtherIncomeNet + if otherSectionsBase < 0 { + otherSectionsBase = 0 + } + rawBase := itr.Section6TaxBase + otherSectionsBase if rawBase < 0 { rawBase = 0 } @@ -249,20 +305,38 @@ func (s *IncomeTaxReturnService) Recalculate(ctx context.Context, id int64) (*do // Pure calculation. taxResult := calc.CalculateIncomeTax(calc.IncomeTaxInput{ - TotalRevenue: itr.TotalRevenue, - ActualExpenses: itr.ActualExpenses, - FlatRatePercent: flatRatePercent, - Constants: constants, - SpouseCredit: spouseCredit, - DisabilityCredit: disabilityCredit, - StudentCredit: studentCredit, - ChildBenefit: childBenefit, - TotalDeductions: totalDeductions, - Prepayments: taxTotal, - CapitalIncomeNet: itr.CapitalIncomeNet, - OtherIncomeNet: itr.OtherIncomeNet, + TotalRevenue: itr.TotalRevenue, + ActualExpenses: itr.ActualExpenses, + FlatRatePercent: flatRatePercent, + Constants: constants, + SpouseCredit: spouseCredit, + DisabilityCredit: disabilityCredit, + StudentCredit: studentCredit, + ChildBenefit: childBenefit, + TotalDeductions: totalDeductions, + Prepayments: taxTotal, + CapitalIncomeNet: itr.CapitalIncomeNet, + OtherIncomeNet: itr.OtherIncomeNet, + Section6TaxBase: itr.Section6TaxBase, + Section6AdvanceWithheld: itr.Section6AdvanceWithheld, + Section6WithholdingCredited: itr.Section6WithholdingCredited, + Section6MonthlyBonusPaid: itr.Section6MonthlyBonusPaid, }) + // Warnings: §16 odst. 1 ZDP progressive 23% rate review when the + // consolidated tax base (§6 + §7 + §8 + §10) crosses 36× průměrná mzda + // for the year. The existing CalculateIncomeTax handles the split + // correctly; the warning prompts the user to verify the result. + itr.Warnings = nil + otherSectionsRaw := itr.TotalRevenue - taxResult.UsedExpenses + itr.CapitalIncomeNet + itr.OtherIncomeNet + if otherSectionsRaw < 0 { + otherSectionsRaw = 0 + } + consolidatedBase := itr.Section6TaxBase + otherSectionsRaw + if consolidatedBase > constants.ProgressiveThreshold { + itr.Warnings = append(itr.Warnings, domain.WarningProgressiveRateReview) + } + // Map result back to entity. itr.FlatRateAmount = taxResult.FlatRateAmount itr.UsedExpenses = taxResult.UsedExpenses diff --git a/internal/service/income_tax_return_svc_test.go b/internal/service/income_tax_return_svc_test.go index 5163190..19d76ea 100644 --- a/internal/service/income_tax_return_svc_test.go +++ b/internal/service/income_tax_return_svc_test.go @@ -617,6 +617,344 @@ func TestIncomeTaxReturnService_Recalculate_DeductionBreakdown(t *testing.T) { } } +// setupIncomeTaxSvcWithEmployment wires the service with the employment +// certificate repo for §6 aggregation tests. +func setupIncomeTaxSvcWithEmployment(t *testing.T) (*IncomeTaxReturnService, *TaxCreditsService, *repository.EmploymentCertificateRepository, *sql.DB) { + t.Helper() + db := testutil.NewTestDB(t) + itrRepo := repository.NewIncomeTaxReturnRepository(db) + invRepo := repository.NewInvoiceRepository(db) + expRepo := repository.NewExpenseRepository(db) + setRepo := repository.NewSettingsRepository(db) + tysRepo := repository.NewTaxYearSettingsRepository(db) + tpRepo := repository.NewTaxPrepaymentRepository(db) + + spouseRepo := repository.NewTaxSpouseCreditRepository(db) + childRepo := repository.NewTaxChildCreditRepository(db) + personalRepo := repository.NewTaxPersonalCreditsRepository(db) + deductionRepo := repository.NewTaxDeductionRepository(db) + creditsSvc := NewTaxCreditsService(spouseRepo, childRepo, personalRepo, deductionRepo, nil) + empRepo := repository.NewEmploymentCertificateRepository(db) + + svc := NewIncomeTaxReturnService(itrRepo, invRepo, expRepo, setRepo, tysRepo, tpRepo, creditsSvc, nil) + svc.SetEmploymentCertificateRepo(empRepo) + return svc, creditsSvc, empRepo, db +} + +// seedConfirmedCert helper inserts a confirmed certificate via the repo. +func seedConfirmedCert(t *testing.T, repo *repository.EmploymentCertificateRepository, cert *domain.EmploymentCertificate) { + t.Helper() + cert.Status = "confirmed" + if err := repo.Create(context.Background(), cert); err != nil { + t.Fatalf("seedConfirmedCert: %v", err) + } +} + +func TestIncomeTaxReturnService_Recalculate_AggregatesSection6(t *testing.T) { + svc, _, empRepo, _ := setupIncomeTaxSvcWithEmployment(t) + ctx := context.Background() + + // Two advance certs and one withholding cert (with include_in_dap=true). + seedConfirmedCert(t, empRepo, &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + ContractType: domain.ContractDPC, + EmployerName: "Acme", + EmployerICO: "12345678", + PeriodFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 6, 30, 0, 0, 0, 0, time.UTC), + GrossIncome: domain.NewAmount(100_000, 0), + ForeignTaxPaid: domain.NewAmount(2_000, 0), + AdvanceTaxWithheld: domain.NewAmount(15_000, 0), + MonthlyBonusPaid: domain.NewAmount(1_500, 0), + }) + seedConfirmedCert(t, empRepo, &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + ContractType: domain.ContractDPP, + EmployerName: "Beta", + EmployerICO: "87654321", + PeriodFrom: time.Date(2025, 7, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + GrossIncome: domain.NewAmount(80_000, 0), + AdvanceTaxWithheld: domain.NewAmount(12_000, 0), + AnnualSettlementRefund: domain.NewAmount(1_000, 0), + }) + seedConfirmedCert(t, empRepo, &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateWithholding, + ContractType: domain.ContractDPP, + EmployerName: "Gamma", + EmployerICO: "11111111", + PeriodFrom: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 4, 30, 0, 0, 0, 0, time.UTC), + GrossIncome: domain.NewAmount(20_000, 0), + WithheldFinalTax: domain.NewAmount(3_000, 0), + IncludeWithholdingInDAP: true, + }) + + itr := &domain.IncomeTaxReturn{Year: 2025, FilingType: domain.FilingTypeRegular} + if err := svc.Create(ctx, itr); err != nil { + t.Fatalf("Create: %v", err) + } + result, err := svc.Recalculate(ctx, itr.ID) + if err != nil { + t.Fatalf("Recalculate: %v", err) + } + + // Section6GrossIncome = 100_000 (Acme) + 80_000 (Beta) + 20_000 (Gamma withholding included). + wantGross := domain.NewAmount(200_000, 0) + if result.Section6GrossIncome != wantGross { + t.Errorf("Section6GrossIncome = %d, want %d", result.Section6GrossIncome, wantGross) + } + // Section6ForeignTax = 2_000 (Acme). + wantForeign := domain.NewAmount(2_000, 0) + if result.Section6ForeignTax != wantForeign { + t.Errorf("Section6ForeignTax = %d, want %d", result.Section6ForeignTax, wantForeign) + } + // Section6TaxBase = Section6GrossIncome - Section6ForeignTax = 200_000 - 2_000. + wantBase := wantGross - wantForeign + if result.Section6TaxBase != wantBase { + t.Errorf("Section6TaxBase = %d, want %d", result.Section6TaxBase, wantBase) + } + // Section6AdvanceWithheld = (15_000 - 0) + (12_000 - 1_000) = 26_000. + wantAdv := domain.NewAmount(26_000, 0) + if result.Section6AdvanceWithheld != wantAdv { + t.Errorf("Section6AdvanceWithheld = %d, want %d", result.Section6AdvanceWithheld, wantAdv) + } + // Section6WithholdingCredited = 3_000 (Gamma). + wantWh := domain.NewAmount(3_000, 0) + if result.Section6WithholdingCredited != wantWh { + t.Errorf("Section6WithholdingCredited = %d, want %d", result.Section6WithholdingCredited, wantWh) + } + // Section6MonthlyBonusPaid = 1_500. + wantBonus := domain.NewAmount(1_500, 0) + if result.Section6MonthlyBonusPaid != wantBonus { + t.Errorf("Section6MonthlyBonusPaid = %d, want %d", result.Section6MonthlyBonusPaid, wantBonus) + } + if result.Section6CertsAdvance != 2 { + t.Errorf("Section6CertsAdvance = %d, want 2", result.Section6CertsAdvance) + } + if result.Section6CertsWithholding != 1 { + t.Errorf("Section6CertsWithholding = %d, want 1", result.Section6CertsWithholding) + } + // Section6TaxBase should also flow into the §16 progressive tax base. + if result.TaxBase < wantBase { + t.Errorf("TaxBase = %d should include Section6TaxBase >= %d", result.TaxBase, wantBase) + } +} + +// TestIncomeTaxReturnService_Recalculate_WithholdingNotIncludedSkipped verifies +// the §38g(6) opt-in: certificates with include_withholding_in_dap=false are +// dropped from §6 totals. +func TestIncomeTaxReturnService_Recalculate_WithholdingNotIncludedSkipped(t *testing.T) { + svc, _, empRepo, _ := setupIncomeTaxSvcWithEmployment(t) + ctx := context.Background() + + seedConfirmedCert(t, empRepo, &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateWithholding, + ContractType: domain.ContractDPP, + EmployerName: "Gamma", + EmployerICO: "11111111", + PeriodFrom: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 4, 30, 0, 0, 0, 0, time.UTC), + GrossIncome: domain.NewAmount(20_000, 0), + WithheldFinalTax: domain.NewAmount(3_000, 0), + IncludeWithholdingInDAP: false, + }) + + itr := &domain.IncomeTaxReturn{Year: 2025, FilingType: domain.FilingTypeRegular} + if err := svc.Create(ctx, itr); err != nil { + t.Fatalf("Create: %v", err) + } + result, err := svc.Recalculate(ctx, itr.ID) + if err != nil { + t.Fatalf("Recalculate: %v", err) + } + if result.Section6GrossIncome != 0 { + t.Errorf("Section6GrossIncome = %d, want 0 (cert opted out)", result.Section6GrossIncome) + } + if result.Section6CertsWithholding != 0 { + t.Errorf("Section6CertsWithholding = %d, want 0", result.Section6CertsWithholding) + } +} + +// TestIncomeTaxReturnService_Recalculate_MonthlyBonusDoesNotReduceChildBenefit +// is a regression for K3: MonthlyBonusPaid (ř.89) is informational about +// employer-paid bonuses and must NOT be subtracted from the calculated child +// benefit (ř.72). The reconciliation happens via ř.84/87/89 vs total tax/bonus. +func TestIncomeTaxReturnService_Recalculate_MonthlyBonusDoesNotReduceChildBenefit(t *testing.T) { + svc, creditsSvc, empRepo, _ := setupIncomeTaxSvcWithEmployment(t) + ctx := context.Background() + + // Configure a child credit so ChildBenefit > 0. + now := time.Now() + childRepoCred := &domain.TaxChildCredit{ + Year: 2025, + ChildName: "Anna", + BirthNumber: "0501010001", + ChildOrder: 1, + MonthsClaimed: 12, + ZTP: false, + CreatedAt: now, + UpdatedAt: now, + } + if err := creditsSvc.CreateChild(ctx, childRepoCred); err != nil { + t.Fatalf("CreateChild: %v", err) + } + + // Confirmed advance cert with monthly bonus paid. + seedConfirmedCert(t, empRepo, &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + ContractType: domain.ContractHPP, + EmployerName: "Acme", + EmployerICO: "12345678", + PeriodFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + GrossIncome: domain.NewAmount(300_000, 0), + AdvanceTaxWithheld: domain.NewAmount(45_000, 0), + MonthlyBonusPaid: domain.NewAmount(10_000, 0), + }) + + itr := &domain.IncomeTaxReturn{Year: 2025, FilingType: domain.FilingTypeRegular} + if err := svc.Create(ctx, itr); err != nil { + t.Fatalf("Create: %v", err) + } + result, err := svc.Recalculate(ctx, itr.ID) + if err != nil { + t.Fatalf("Recalculate: %v", err) + } + + // MonthlyBonusPaid is captured. + wantBonus := domain.NewAmount(10_000, 0) + if result.Section6MonthlyBonusPaid != wantBonus { + t.Errorf("Section6MonthlyBonusPaid = %d, want %d", result.Section6MonthlyBonusPaid, wantBonus) + } + // ChildBenefit must equal the full computed annual amount, untouched by + // the §6 employer-paid bonus. + expectedChildBenefit, err := creditsSvc.ComputeChildBenefit(ctx, 2025) + if err != nil { + t.Fatalf("ComputeChildBenefit: %v", err) + } + if result.ChildBenefit != expectedChildBenefit { + t.Errorf("ChildBenefit = %d, want %d (must NOT be reduced by Section6MonthlyBonusPaid)", result.ChildBenefit, expectedChildBenefit) + } +} + +// TestIncomeTaxReturnService_Recalculate_Section6CertsBonusStaysZero is a +// regression for N5: Section6CertsBonus tracks the count of standalone +// "Potvrzení o vyplaceném daňovém bonusu" forms, NOT advance certs that +// happen to include MonthlyBonusPaid > 0. MVP keeps it at 0. +func TestIncomeTaxReturnService_Recalculate_Section6CertsBonusStaysZero(t *testing.T) { + svc, _, empRepo, _ := setupIncomeTaxSvcWithEmployment(t) + ctx := context.Background() + + seedConfirmedCert(t, empRepo, &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + ContractType: domain.ContractHPP, + EmployerName: "Acme", + EmployerICO: "12345678", + PeriodFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + GrossIncome: domain.NewAmount(300_000, 0), + AdvanceTaxWithheld: domain.NewAmount(45_000, 0), + MonthlyBonusPaid: domain.NewAmount(10_000, 0), + }) + + itr := &domain.IncomeTaxReturn{Year: 2025, FilingType: domain.FilingTypeRegular} + if err := svc.Create(ctx, itr); err != nil { + t.Fatalf("Create: %v", err) + } + result, err := svc.Recalculate(ctx, itr.ID) + if err != nil { + t.Fatalf("Recalculate: %v", err) + } + if result.Section6CertsBonus != 0 { + t.Errorf("Section6CertsBonus = %d, want 0 (advance certs with bonus must NOT bump this counter)", result.Section6CertsBonus) + } + if result.Section6CertsAdvance != 1 { + t.Errorf("Section6CertsAdvance = %d, want 1", result.Section6CertsAdvance) + } +} + +// TestRecalculate_EmitsProgressiveRateWarning verifies the +// WarningProgressiveRateReview token is appended when consolidated tax base +// crosses 36× průměrná mzda for 2025 (1 676 052 Kč). +func TestRecalculate_EmitsProgressiveRateWarning(t *testing.T) { + svc, _, empRepo, _ := setupIncomeTaxSvcWithEmployment(t) + ctx := context.Background() + + // Single advance cert with gross income > threshold. + seedConfirmedCert(t, empRepo, &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + ContractType: domain.ContractHPP, + EmployerName: "BigPay", + EmployerICO: "27082440", + PeriodFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + GrossIncome: domain.NewAmount(2_000_000, 0), // > 1 676 052 Kč + AdvanceTaxWithheld: domain.NewAmount(300_000, 0), + }) + + itr := &domain.IncomeTaxReturn{Year: 2025, FilingType: domain.FilingTypeRegular} + if err := svc.Create(ctx, itr); err != nil { + t.Fatalf("Create: %v", err) + } + result, err := svc.Recalculate(ctx, itr.ID) + if err != nil { + t.Fatalf("Recalculate: %v", err) + } + + found := false + for _, w := range result.Warnings { + if w == domain.WarningProgressiveRateReview { + found = true + break + } + } + if !found { + t.Errorf("Warnings = %v, want to contain %q", result.Warnings, domain.WarningProgressiveRateReview) + } +} + +// TestRecalculate_NoWarningBelowThreshold verifies no warning is emitted +// when the consolidated base sits comfortably below 36× průměrná mzda. +func TestRecalculate_NoWarningBelowThreshold(t *testing.T) { + svc, _, empRepo, _ := setupIncomeTaxSvcWithEmployment(t) + ctx := context.Background() + + seedConfirmedCert(t, empRepo, &domain.EmploymentCertificate{ + Year: 2025, + CertificateType: domain.CertificateAdvance, + ContractType: domain.ContractDPC, + EmployerName: "SmallPay", + EmployerICO: "27082440", + PeriodFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + PeriodTo: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + GrossIncome: domain.NewAmount(200_000, 0), + AdvanceTaxWithheld: domain.NewAmount(30_000, 0), + }) + + itr := &domain.IncomeTaxReturn{Year: 2025, FilingType: domain.FilingTypeRegular} + if err := svc.Create(ctx, itr); err != nil { + t.Fatalf("Create: %v", err) + } + result, err := svc.Recalculate(ctx, itr.ID) + if err != nil { + t.Fatalf("Recalculate: %v", err) + } + + for _, w := range result.Warnings { + if w == domain.WarningProgressiveRateReview { + t.Errorf("unexpected progressive rate warning at low base: Warnings = %v", result.Warnings) + } + } +} + func TestIncomeTaxReturnService_Recalculate_WithPrepayments(t *testing.T) { svc, db := setupIncomeTaxSvc(t) ctx := context.Background() diff --git a/internal/service/ocr/employment_prompt.go b/internal/service/ocr/employment_prompt.go new file mode 100644 index 0000000..3147d9a --- /dev/null +++ b/internal/service/ocr/employment_prompt.go @@ -0,0 +1,127 @@ +package ocr + +import ( + "encoding/json" + "fmt" +) + +const employmentSystemPrompt = `Jsi specialista na analyzu ceskych Potvrzeni o zdanitelnych prijmech ze zavisle cinnosti (§ 6 ZDP). Analyzuj prilozeny dokument a extrahuj strukturovana data potrebna pro vyplneni Priznani k dani z prijmu fyzickych osob (DPFO). + +Identifikace varianty formulare (podle hlavicky / nazvu dokumentu): +- "25 5460 MFin 5460 - vzor c. 33" nebo "Potvrzeni o zdanitelnych prijmech ze zavisle cinnosti" -> certificate_type = "advance" (zalohove zdaneni; sraz zaloh dle § 38h ZDP) +- "25 5460/A MFin 5460/A - vzor c. 12" nebo "Potvrzeni o vyplacenych prijmech ... srazenou dani zvlastni sazbou" -> certificate_type = "withholding" (srazkove zdaneni dle § 36 odst. 6/7 ZDP) + +Identifikace zamestnavatele a obdobi: +- employer_name = nazev plátce dane (zamestnavatele) +- employer_ico = ICO plátce (8-mistne cislo) +- employer_address = adresa sidla plátce +- period_from / period_to = zdanovaci obdobi (zacatek / konec; format YYYY-MM-DD) + +Detekce typu pracovniho pomeru / smlouvy (z textu Potvrzeni nebo prilozenych poznamek): +- "Dohoda o pracovni cinnosti" / "DPC" -> contract_type = "dpc" +- "Dohoda o provedeni prace" / "DPP" -> contract_type = "dpp" +- "Pracovni pomer" / "HPP" / "hlavni pracovni pomer" -> contract_type = "hpp" +- jinak / nelze urcit -> contract_type = "other" + +Extrakce castek pro variantu vzor 33 (advance) — radek po radku z Potvrzeni: +- r.2 = uhrn zuctovanych prijmu ze zavisle cinnosti +- r.4 = dalsi zdanitelne prijmy (napr. nepenezni plneni, benefity nad limit) +- r.5 = uhrn mesicnich danovych bonusu vyplacenych zamestnavatelem (cast 1) +- r.8 = uhrn srazenych zaloh na dan po slevach +- r.13 = uhrn mesicnich danovych bonusu (cast 2 / pripadny doplatek z RZ) +- polozka "vraceny preplatek z rocniho zuctovani" (pokud je uvedena) + +Mapovani na vystupni JSON (pro vzor 33): +- gross_income_czk = r.2 + r.4 (DULEZITE: NIKOLIV jen r.2 nebo jen r.4) +- monthly_bonus_paid_czk = r.5 + r.13 (per oficialni Pokyny 2025 a XSD doc na atributu kc_vyplbonus: "soucet r.5 a 13") +- advance_tax_withheld_czk = r.8 +- annual_settlement_refund_czk = vraceny preplatek z rocniho zuctovani (snizuje sraz. zalohy) +- foreign_tax_paid_czk = dan zaplacena v zahranici dle § 6 odst. 13 ZDP (muze chybet -> 0) +- income_without_advance_czk = cast prijmu, u kterych plátce nemel povinnost srazit zalohy dle § 38h ZDP (typicky prijmy zamestnancu zahranicnich zastupitelskych uradu v CR a prijmy ze zahranicniho zamestnavatele bez stale provozovny v CR; muze chybet -> 0) + +Extrakce castek pro variantu vzor 12 (withholding): +- r.2 = uhrn vyplacenych prijmu +- polozka "srazena dan zvlastni sazbou" (typicky uvedena pod r.2) + +Mapovani na vystupni JSON (pro vzor 12): +- gross_income_czk = r.2 +- withheld_final_tax_czk = srazena dan zvlastni sazbou +- pole urcena pro vzor 33 (advance_tax_withheld_czk, monthly_bonus_paid_czk, annual_settlement_refund_czk, foreign_tax_paid_czk, income_without_advance_czk) zustavaji 0 + +Vrat POUZE platny JSON objekt (bez markdown, bez komentaru) s nasledujici strukturou: +{ + "certificate_type": "advance|withholding", + "employer_name": "...", + "employer_ico": "...", + "employer_address": "...", + "contract_type": "dpc|dpp|hpp|other", + "period_from": "YYYY-MM-DD", + "period_to": "YYYY-MM-DD", + "gross_income_czk": 0.0, + "income_without_advance_czk": 0.0, + "foreign_tax_paid_czk": 0.0, + "advance_tax_withheld_czk": 0.0, + "annual_settlement_refund_czk": 0.0, + "monthly_bonus_paid_czk": 0.0, + "withheld_final_tax_czk": 0.0, + "confidence": 0.0, + "raw_text": "..." +} + +Dulezite: +- Castky jsou v CZK jako desetinna cisla (napr. 1234.56 = 1234 Kc a 56 haleru). Tato vrstva NEPREVADI na halere — to dela aplikacni vrstva. +- Datum vzdy ve formatu YYYY-MM-DD. +- Pokud udaj neni na dokladu, pouzij prazdny retezec pro textova pole, 0 pro cisla. +- raw_text omez na maximalne 2000 znaku (pro ucely auditu). +- confidence v intervalu [0.0, 1.0] podle toho, jak jsi si jisty spravnosti extrakce (priblizny vodítka: < 0.5 = nizka jistota, 0.5–0.8 = stredni, > 0.8 = vysoka). +- ICO uved jako retezec presne 8 cislic (vcetne pripadnych vodicich nul).` + +const employmentUserPrompt = `Analyzuj prilozene Potvrzeni o zdanitelnych prijmech ze zavisle cinnosti (vzor 33) nebo Potvrzeni o vyplacenych prijmech a srazene dani zvlastni sazbou (vzor 12) a extrahuj vsechna pozadovana data do JSON formatu podle zadane struktury.` + +// EmploymentSystemPrompt returns the Czech system prompt for §6 employment income +// certificate extraction (Potvrzeni o zdanitelnych prijmech ze zavisle cinnosti). +func EmploymentSystemPrompt() string { + return employmentSystemPrompt +} + +// EmploymentUserPrompt returns the user instruction prefix for employment certificate OCR. +func EmploymentUserPrompt() string { + return employmentUserPrompt +} + +// EmploymentExtractionResponse is the expected JSON structure returned by the AI model +// when processing a §6 employment income certificate. +// +// JSON tags are present here because this is the OCR-layer DTO; downstream services +// convert these float CZK values to domain.Amount (halere) before persisting. +type EmploymentExtractionResponse struct { + CertificateType string `json:"certificate_type"` + EmployerName string `json:"employer_name"` + EmployerICO string `json:"employer_ico"` + EmployerAddress string `json:"employer_address"` + ContractType string `json:"contract_type"` + PeriodFrom string `json:"period_from"` + PeriodTo string `json:"period_to"` + GrossIncomeCZK float64 `json:"gross_income_czk"` + IncomeWithoutAdvanceCZK float64 `json:"income_without_advance_czk"` + ForeignTaxPaidCZK float64 `json:"foreign_tax_paid_czk"` + AdvanceTaxWithheldCZK float64 `json:"advance_tax_withheld_czk"` + AnnualSettlementRefundCZK float64 `json:"annual_settlement_refund_czk"` + MonthlyBonusPaidCZK float64 `json:"monthly_bonus_paid_czk"` + WithheldFinalTaxCZK float64 `json:"withheld_final_tax_czk"` + Confidence float64 `json:"confidence"` + RawText string `json:"raw_text"` +} + +// ParseEmploymentResponse parses the raw AI model output into an EmploymentExtractionResponse. +// Markdown code fences are stripped before JSON parsing. +func ParseEmploymentResponse(raw string) (*EmploymentExtractionResponse, error) { + content := stripCodeFences(raw) + + var resp EmploymentExtractionResponse + if err := json.Unmarshal([]byte(content), &resp); err != nil { + return nil, fmt.Errorf("parsing employment JSON from model output: %w", err) + } + + return &resp, nil +} diff --git a/internal/service/ocr/employment_prompt_test.go b/internal/service/ocr/employment_prompt_test.go new file mode 100644 index 0000000..4bf1d8c --- /dev/null +++ b/internal/service/ocr/employment_prompt_test.go @@ -0,0 +1,342 @@ +package ocr + +import ( + "strings" + "testing" +) + +func TestParseEmploymentResponse_AdvanceVariant(t *testing.T) { + input := `{ + "certificate_type": "advance", + "employer_name": "Acme s.r.o.", + "employer_ico": "12345678", + "employer_address": "Vaclavske namesti 1, 110 00 Praha 1", + "contract_type": "dpc", + "period_from": "2025-01-01", + "period_to": "2025-12-31", + "gross_income_czk": 240000.00, + "income_without_advance_czk": 0.00, + "foreign_tax_paid_czk": 0.00, + "advance_tax_withheld_czk": 36000.00, + "annual_settlement_refund_czk": 1500.00, + "monthly_bonus_paid_czk": 15300.00, + "withheld_final_tax_czk": 0.00, + "confidence": 0.92, + "raw_text": "Potvrzeni o zdanitelnych prijmech ze zavisle cinnosti..." + }` + + resp, err := ParseEmploymentResponse(input) + if err != nil { + t.Fatalf("ParseEmploymentResponse() error: %v", err) + } + + if resp.CertificateType != "advance" { + t.Errorf("CertificateType = %q, want %q", resp.CertificateType, "advance") + } + if resp.EmployerName != "Acme s.r.o." { + t.Errorf("EmployerName = %q, want %q", resp.EmployerName, "Acme s.r.o.") + } + if resp.EmployerICO != "12345678" { + t.Errorf("EmployerICO = %q, want %q", resp.EmployerICO, "12345678") + } + if resp.ContractType != "dpc" { + t.Errorf("ContractType = %q, want %q", resp.ContractType, "dpc") + } + if resp.PeriodFrom != "2025-01-01" { + t.Errorf("PeriodFrom = %q, want %q", resp.PeriodFrom, "2025-01-01") + } + if resp.PeriodTo != "2025-12-31" { + t.Errorf("PeriodTo = %q, want %q", resp.PeriodTo, "2025-12-31") + } + if resp.GrossIncomeCZK != 240000.00 { + t.Errorf("GrossIncomeCZK = %f, want %f", resp.GrossIncomeCZK, 240000.00) + } + if resp.AdvanceTaxWithheldCZK != 36000.00 { + t.Errorf("AdvanceTaxWithheldCZK = %f, want %f", resp.AdvanceTaxWithheldCZK, 36000.00) + } + if resp.AnnualSettlementRefundCZK != 1500.00 { + t.Errorf("AnnualSettlementRefundCZK = %f, want %f", resp.AnnualSettlementRefundCZK, 1500.00) + } + if resp.MonthlyBonusPaidCZK != 15300.00 { + t.Errorf("MonthlyBonusPaidCZK = %f, want %f", resp.MonthlyBonusPaidCZK, 15300.00) + } + if resp.WithheldFinalTaxCZK != 0.00 { + t.Errorf("WithheldFinalTaxCZK = %f, want 0", resp.WithheldFinalTaxCZK) + } + if resp.Confidence != 0.92 { + t.Errorf("Confidence = %f, want %f", resp.Confidence, 0.92) + } + if !strings.Contains(resp.RawText, "Potvrzeni") { + t.Errorf("RawText = %q, expected to contain 'Potvrzeni'", resp.RawText) + } +} + +func TestParseEmploymentResponse_WithholdingVariant(t *testing.T) { + input := `{ + "certificate_type": "withholding", + "employer_name": "Beta a.s.", + "employer_ico": "87654321", + "employer_address": "Brno, Stara 5", + "contract_type": "dpp", + "period_from": "2025-03-01", + "period_to": "2025-08-31", + "gross_income_czk": 50000.00, + "income_without_advance_czk": 0.00, + "foreign_tax_paid_czk": 0.00, + "advance_tax_withheld_czk": 0.00, + "annual_settlement_refund_czk": 0.00, + "monthly_bonus_paid_czk": 0.00, + "withheld_final_tax_czk": 7500.00, + "confidence": 0.88, + "raw_text": "Potvrzeni 25 5460/A vzor c. 12 ..." + }` + + resp, err := ParseEmploymentResponse(input) + if err != nil { + t.Fatalf("ParseEmploymentResponse() error: %v", err) + } + + if resp.CertificateType != "withholding" { + t.Errorf("CertificateType = %q, want %q", resp.CertificateType, "withholding") + } + if resp.ContractType != "dpp" { + t.Errorf("ContractType = %q, want %q", resp.ContractType, "dpp") + } + if resp.GrossIncomeCZK != 50000.00 { + t.Errorf("GrossIncomeCZK = %f, want %f", resp.GrossIncomeCZK, 50000.00) + } + if resp.WithheldFinalTaxCZK != 7500.00 { + t.Errorf("WithheldFinalTaxCZK = %f, want %f", resp.WithheldFinalTaxCZK, 7500.00) + } + if resp.AdvanceTaxWithheldCZK != 0.00 { + t.Errorf("AdvanceTaxWithheldCZK = %f, want 0 for withholding variant", resp.AdvanceTaxWithheldCZK) + } + if resp.MonthlyBonusPaidCZK != 0.00 { + t.Errorf("MonthlyBonusPaidCZK = %f, want 0 for withholding variant", resp.MonthlyBonusPaidCZK) + } +} + +func TestParseEmploymentResponse_MissingFieldsDefaultToZero(t *testing.T) { + // Minimal JSON: only the certificate type and one amount, all others should default. + input := `{ + "certificate_type": "advance", + "gross_income_czk": 120000.0 + }` + + resp, err := ParseEmploymentResponse(input) + if err != nil { + t.Fatalf("ParseEmploymentResponse() error: %v", err) + } + + if resp.CertificateType != "advance" { + t.Errorf("CertificateType = %q, want %q", resp.CertificateType, "advance") + } + if resp.EmployerName != "" { + t.Errorf("EmployerName = %q, want empty string", resp.EmployerName) + } + if resp.EmployerICO != "" { + t.Errorf("EmployerICO = %q, want empty string", resp.EmployerICO) + } + if resp.EmployerAddress != "" { + t.Errorf("EmployerAddress = %q, want empty string", resp.EmployerAddress) + } + if resp.ContractType != "" { + t.Errorf("ContractType = %q, want empty string", resp.ContractType) + } + if resp.PeriodFrom != "" { + t.Errorf("PeriodFrom = %q, want empty string", resp.PeriodFrom) + } + if resp.PeriodTo != "" { + t.Errorf("PeriodTo = %q, want empty string", resp.PeriodTo) + } + if resp.GrossIncomeCZK != 120000.0 { + t.Errorf("GrossIncomeCZK = %f, want %f", resp.GrossIncomeCZK, 120000.0) + } + if resp.IncomeWithoutAdvanceCZK != 0.0 { + t.Errorf("IncomeWithoutAdvanceCZK = %f, want 0", resp.IncomeWithoutAdvanceCZK) + } + if resp.ForeignTaxPaidCZK != 0.0 { + t.Errorf("ForeignTaxPaidCZK = %f, want 0", resp.ForeignTaxPaidCZK) + } + if resp.AdvanceTaxWithheldCZK != 0.0 { + t.Errorf("AdvanceTaxWithheldCZK = %f, want 0", resp.AdvanceTaxWithheldCZK) + } + if resp.AnnualSettlementRefundCZK != 0.0 { + t.Errorf("AnnualSettlementRefundCZK = %f, want 0", resp.AnnualSettlementRefundCZK) + } + if resp.MonthlyBonusPaidCZK != 0.0 { + t.Errorf("MonthlyBonusPaidCZK = %f, want 0", resp.MonthlyBonusPaidCZK) + } + if resp.WithheldFinalTaxCZK != 0.0 { + t.Errorf("WithheldFinalTaxCZK = %f, want 0", resp.WithheldFinalTaxCZK) + } + if resp.Confidence != 0.0 { + t.Errorf("Confidence = %f, want 0", resp.Confidence) + } + if resp.RawText != "" { + t.Errorf("RawText = %q, want empty string", resp.RawText) + } +} + +func TestParseEmploymentResponse_StripsCodeFences(t *testing.T) { + input := "```json\n{\"certificate_type\": \"advance\", \"employer_name\": \"Gamma s.r.o.\", \"gross_income_czk\": 60000.0, \"confidence\": 0.7}\n```" + + resp, err := ParseEmploymentResponse(input) + if err != nil { + t.Fatalf("ParseEmploymentResponse() error: %v", err) + } + + if resp.CertificateType != "advance" { + t.Errorf("CertificateType = %q, want %q", resp.CertificateType, "advance") + } + if resp.EmployerName != "Gamma s.r.o." { + t.Errorf("EmployerName = %q, want %q", resp.EmployerName, "Gamma s.r.o.") + } + if resp.GrossIncomeCZK != 60000.0 { + t.Errorf("GrossIncomeCZK = %f, want %f", resp.GrossIncomeCZK, 60000.0) + } +} + +func TestParseEmploymentResponse_StripsBareCodeFences(t *testing.T) { + input := "```\n{\"certificate_type\": \"withholding\", \"gross_income_czk\": 30000.0}\n```" + + resp, err := ParseEmploymentResponse(input) + if err != nil { + t.Fatalf("ParseEmploymentResponse() error: %v", err) + } + + if resp.CertificateType != "withholding" { + t.Errorf("CertificateType = %q, want %q", resp.CertificateType, "withholding") + } + if resp.GrossIncomeCZK != 30000.0 { + t.Errorf("GrossIncomeCZK = %f, want %f", resp.GrossIncomeCZK, 30000.0) + } +} + +func TestParseEmploymentResponse_MalformedJSON(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"empty string", ""}, + {"plain text", "not valid json at all"}, + {"unterminated object", `{"certificate_type": "advance"`}, + {"trailing garbage", `{"certificate_type": "advance"} extra`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseEmploymentResponse(tt.input) + if err == nil { + t.Errorf("expected error for malformed input %q", tt.input) + } + }) + } +} + +func TestParseEmploymentResponse_LowConfidencePropagation(t *testing.T) { + // The OCR-layer module must not silently filter / warn on low confidence; + // it just reads the field through. Downstream services decide what to do. + input := `{ + "certificate_type": "advance", + "gross_income_czk": 100000.0, + "confidence": 0.32, + "raw_text": "blurry scan" + }` + + resp, err := ParseEmploymentResponse(input) + if err != nil { + t.Fatalf("ParseEmploymentResponse() error: %v", err) + } + + if resp.Confidence != 0.32 { + t.Errorf("Confidence = %f, want %f (must be propagated verbatim)", resp.Confidence, 0.32) + } + if resp.GrossIncomeCZK != 100000.0 { + t.Errorf("GrossIncomeCZK = %f, want %f", resp.GrossIncomeCZK, 100000.0) + } +} + +func TestParseEmploymentResponse_AmountsAsRawFloat(t *testing.T) { + // Verify that the OCR layer does NOT convert CZK to halere — callers do that + // via domain.AmountFromFloat or similar helpers. + input := `{ + "certificate_type": "advance", + "gross_income_czk": 1234.56, + "advance_tax_withheld_czk": 78.90, + "monthly_bonus_paid_czk": 0.01 + }` + + resp, err := ParseEmploymentResponse(input) + if err != nil { + t.Fatalf("ParseEmploymentResponse() error: %v", err) + } + + if resp.GrossIncomeCZK != 1234.56 { + t.Errorf("GrossIncomeCZK = %f, want %f (must stay as float CZK, no halere conversion)", resp.GrossIncomeCZK, 1234.56) + } + if resp.AdvanceTaxWithheldCZK != 78.90 { + t.Errorf("AdvanceTaxWithheldCZK = %f, want %f", resp.AdvanceTaxWithheldCZK, 78.90) + } + if resp.MonthlyBonusPaidCZK != 0.01 { + t.Errorf("MonthlyBonusPaidCZK = %f, want %f", resp.MonthlyBonusPaidCZK, 0.01) + } +} + +func TestEmploymentSystemPrompt_ContainsKeyInstructions(t *testing.T) { + prompt := EmploymentSystemPrompt() + if prompt == "" { + t.Fatal("expected non-empty system prompt") + } + + mustContain := []string{ + "JSON", + "certificate_type", + "advance", + "withholding", + "vzor c. 33", + "vzor c. 12", + "r.2", + "r.4", + "r.5", + "r.8", + "r.13", + "gross_income_czk", + "monthly_bonus_paid_czk", + "r.5 + r.13", // critical correctness note: NOT just r.13 + "income_without_advance_czk", + "§ 38h", + "contract_type", + "dpc", + "dpp", + "hpp", + "YYYY-MM-DD", + "raw_text", + "confidence", + "2000", + } + for _, s := range mustContain { + if !strings.Contains(prompt, s) { + t.Errorf("expected system prompt to contain %q, but it did not", s) + } + } +} + +func TestEmploymentSystemPrompt_GrossIncomeFormulaDocumented(t *testing.T) { + // Critical: the prompt must explicitly say gross_income_czk for advance variant + // is r.2 + r.4 (not just one of them). + prompt := EmploymentSystemPrompt() + if !strings.Contains(prompt, "r.2 + r.4") { + t.Error("expected system prompt to specify 'gross_income_czk = r.2 + r.4' for advance variant") + } +} + +func TestEmploymentUserPrompt_NonEmpty(t *testing.T) { + prompt := EmploymentUserPrompt() + if prompt == "" { + t.Fatal("expected non-empty user prompt") + } + if !strings.Contains(prompt, "Potvrzeni") { + t.Errorf("expected user prompt to mention 'Potvrzeni', got: %q", prompt) + } +}