diff --git a/frontend/src/routes/settings/firma/+page.svelte b/frontend/src/routes/settings/firma/+page.svelte index d8ddb41..ff90a2e 100644 --- a/frontend/src/routes/settings/firma/+page.svelte +++ b/frontend/src/routes/settings/firma/+page.svelte @@ -111,7 +111,17 @@ class="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-primary focus:border-accent focus:ring-1 focus:ring-accent/50 focus:outline-none" /> -
+
+
+ + setField('title', (e.target as HTMLInputElement).value)} + class="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-primary focus:border-accent focus:ring-1 focus:ring-accent/50 focus:outline-none" + /> +
+
+ + setField('databox_id', (e.target as HTMLInputElement).value)} + class="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-primary focus:border-accent focus:ring-1 focus:ring-accent/50 focus:outline-none" + /> +
@@ -445,7 +467,20 @@ value={field('cssz_code')} oninput={(e) => setField('cssz_code', (e.target as HTMLInputElement).value)} class="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-primary focus:border-accent focus:ring-1 focus:ring-accent/50 focus:outline-none" - placeholder="např. prehledosvc" + placeholder="např. 775" + /> +
+
+ + + setField('cssz_variable_symbol', (e.target as HTMLInputElement).value)} + class="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-primary focus:border-accent focus:ring-1 focus:ring-accent/50 focus:outline-none" />
diff --git a/frontend/src/routes/settings/firma/page.test.ts b/frontend/src/routes/settings/firma/page.test.ts index 0029393..73a5101 100644 --- a/frontend/src/routes/settings/firma/page.test.ts +++ b/frontend/src/routes/settings/firma/page.test.ts @@ -15,6 +15,7 @@ function jsonResponse(data: unknown, status = 200) { const sampleSettings = { company_name: 'Test OSVC', + title: 'Ing.', first_name: 'Jan', last_name: 'Novak', ico: '12345678', @@ -26,13 +27,16 @@ const sampleSettings = { zip: '10000', email: 'test@example.com', phone: '+420123456789', + databox_id: '7rgmm99', bank_account: '123456789', bank_code: '0100', iban: 'CZ1234567890', swift: 'KOMBCZPP', c_ufo: '464', c_pracufo: '3305', - c_okec: '582900' + c_okec: '582900', + cssz_code: '775', + cssz_variable_symbol: '75660248' }; beforeEach(() => { @@ -64,6 +68,7 @@ describe('Settings Firma Page', () => { }); expect(document.querySelector('#first_name')).toBeInTheDocument(); expect(document.querySelector('#last_name')).toBeInTheDocument(); + expect(document.querySelector('#title')).toBeInTheDocument(); expect(document.querySelector('#ico')).toBeInTheDocument(); expect(document.querySelector('#dic')).toBeInTheDocument(); expect(document.querySelector('#vat_registered')).toBeInTheDocument(); @@ -73,6 +78,7 @@ describe('Settings Firma Page', () => { expect(document.querySelector('#zip')).toBeInTheDocument(); expect(document.querySelector('#email')).toBeInTheDocument(); expect(document.querySelector('#phone')).toBeInTheDocument(); + expect(document.querySelector('#databox_id')).toBeInTheDocument(); expect(document.querySelector('#bank_account')).toBeInTheDocument(); expect(document.querySelector('#bank_code')).toBeInTheDocument(); expect(document.querySelector('#iban')).toBeInTheDocument(); @@ -80,6 +86,8 @@ describe('Settings Firma Page', () => { expect(document.querySelector('#c_ufo')).toBeInTheDocument(); expect(document.querySelector('#c_pracufo')).toBeInTheDocument(); expect(document.querySelector('#c_okec')).toBeInTheDocument(); + expect(document.querySelector('#cssz_code')).toBeInTheDocument(); + expect(document.querySelector('#cssz_variable_symbol')).toBeInTheDocument(); }); it('shows settings values in inputs', async () => { diff --git a/internal/annualtaxxml/social_insurance_gen.go b/internal/annualtaxxml/social_insurance_gen.go index 91d42a2..eeb6ff8 100644 --- a/internal/annualtaxxml/social_insurance_gen.go +++ b/internal/annualtaxxml/social_insurance_gen.go @@ -3,34 +3,38 @@ package annualtaxxml import ( "encoding/xml" "fmt" + "regexp" "strconv" + "strings" "github.com/zajca/zfaktury/internal/domain" ) +var csszDepCodePattern = regexp.MustCompile(`^\d{3}$`) + // GenerateSocialInsuranceXML produces CSSZ OSVC overview XML from a SocialInsuranceOverview // and application settings containing taxpayer information. func GenerateSocialInsuranceXML(sio *domain.SocialInsuranceOverview, settings map[string]string) ([]byte, error) { if sio == nil { return nil, fmt.Errorf("social insurance overview is nil: %w", domain.ErrInvalidInput) } + if err := validateSocialInsuranceXMLInput(settings); err != nil { + return nil, err + } rok := strconv.Itoa(sio.Year) assessmentBase := ToWholeCZK(sio.AssessmentBase) - minAssessmentBase := ToWholeCZK(sio.MinAssessmentBase) finalAssessmentBase := ToWholeCZK(sio.FinalAssessmentBase) totalInsurance := ToWholeCZK(sio.TotalInsurance) prepayments := ToWholeCZK(sio.Prepayments) difference := ToWholeCZK(sio.Difference) - totalRevenue := ToWholeCZK(sio.TotalRevenue) - totalExpenses := ToWholeCZK(sio.TotalExpenses) newMonthlyPrepay := ToWholeCZK(sio.NewMonthlyPrepay) + monthlyAssessmentBase := divideRoundedUp(finalAssessmentBase, 12) - // Determine flat-rate flag for zal section. - pauFlag := "N" - if settings["flat_rate_expenses"] == "true" { - pauFlag = "A" + priznElektr := "A" + if v := strings.TrimSpace(settings["tax_return_filed_electronically"]); v != "" { + priznElektr = v } doc := &OSVC{ @@ -43,21 +47,21 @@ func GenerateSocialInsuranceXML(sio *domain.SocialInsuranceOverview, settings ma Sender: Sender{ EmailNotifikace: "", ISDSreport: "3", - VerzeProtokolu: "1", + VerzeProtokolu: "1.0", }, Prehled: PrehledOSVC{ - For: settings["cssz_code"], - Dep: "122", + For: "prehledosvc", + Dep: strings.TrimSpace(settings["cssz_code"]), Rok: rok, Typ: CSSZFilingTypeCode(sio.FilingType), - VSDP: "", + VSDP: strings.TrimSpace(settings["cssz_variable_symbol"]), Dat: "", Client: Client{ Name: ClientName{ Fir: settings["taxpayer_first_name"], Sur: settings["taxpayer_last_name"], - Tit: "", + Tit: settings["taxpayer_title"], }, Birth: ClientBirth{ Bno: settings["taxpayer_birth_number"], @@ -70,43 +74,46 @@ func GenerateSocialInsuranceXML(sio *domain.SocialInsuranceOverview, settings ma Cit: settings["taxpayer_city"], Cnt: "CZ", }, - Druc: "H", + IDDS: settings["taxpayer_databox_id"], + Email: settings["taxpayer_email"], + Tel: settings["taxpayer_phone"], + Druc: "H", Hlavc: Hlavc{ - MonthFlags: MonthFlags{M1: "A"}, + MonthFlags: MonthFlags{M13: "A"}, }, Vedc: Vedc{}, - Sleva: MonthFlags{M1: "n"}, + Sleva: MonthFlags{}, }, PVV: PVV{ - Pri: "1", - Mesc: HVPair{H: "1", V: ""}, - Mesv: HVPair{H: "1", V: ""}, - Mesp: "1", - Rdza: HVPair{ - H: strconv.FormatInt(totalRevenue, 10), - V: strconv.FormatInt(totalExpenses, 10), - }, + Pri: formatAmount(sio.TaxBase), + Mesc: HVPair{H: "12", V: ""}, + Mesv: HVPair{H: "12", V: ""}, + Mesp: formatMonthlyAverage(sio.TaxBase, 12), + Rdza: HVPair{H: "0", V: "0"}, VVZ: HVPair{ H: strconv.FormatInt(assessmentBase, 10), V: "0", }, - DVZ: HVPair{H: "0", V: "0"}, - MVZ: strconv.FormatInt(minAssessmentBase, 10), - UVZ: strconv.FormatInt(finalAssessmentBase, 10), - Vzsu: strconv.FormatInt(finalAssessmentBase, 10), - Vzsvc: strconv.FormatInt(finalAssessmentBase, 10), - Poj: strconv.FormatInt(totalInsurance, 10), - Zal: strconv.FormatInt(prepayments, 10), - Ned: strconv.FormatInt(difference, 10), + DVZ: HVPair{H: "0", V: "0"}, + MVZ: strconv.FormatInt(finalAssessmentBase, 10), + UVZ: strconv.FormatInt(finalAssessmentBase, 10), + Vzsu: strconv.FormatInt(finalAssessmentBase, 10), + Vzsvc: strconv.FormatInt(finalAssessmentBase, 10), + Poj: strconv.FormatInt(totalInsurance, 10), + Slev: "0", + Pojposlev: strconv.FormatInt(totalInsurance, 10), + Zal: strconv.FormatInt(prepayments, 10), + Ned: strconv.FormatInt(difference, 10), }, Zal: Zal{ - Pau: pauFlag, - VZ: strconv.FormatInt(finalAssessmentBase, 10), + Ved: "H", + Pau: settings["flat_tax_regime"], + VZ: strconv.FormatInt(monthlyAssessmentBase, 10), DP: strconv.FormatInt(newMonthlyPrepay, 10), - NP: "0", - Duch: "", + NP: strings.TrimSpace(settings["sickness_insurance_monthly"]), + Duch: strings.TrimSpace(settings["working_pensioner_discount"]), }, Pre: Pre{ @@ -115,15 +122,20 @@ func GenerateSocialInsuranceXML(sio *domain.SocialInsuranceOverview, settings ma }, Prizn: Prizn{ - Pau: "H", + Pau: settings["flat_tax_regime_reason"], Pov: "A", - Elektr: "N", - Por: "N", + Elektr: priznElektr, + Por: settings["tax_advisor_after_april"], }, Spo: Spo{ Name: SpoName{}, }, + Prilo: Prilo{ + Coun: "0", + Plnamoc: "N", + Jina: "N", + }, }, } @@ -139,3 +151,55 @@ func GenerateSocialInsuranceXML(sio *domain.SocialInsuranceOverview, settings ma return result, nil } + +func validateSocialInsuranceXMLInput(settings map[string]string) error { + required := []struct { + label string + key string + }{ + {"kód OSSZ", "cssz_code"}, + {"jméno", "taxpayer_first_name"}, + {"příjmení", "taxpayer_last_name"}, + {"rodné číslo / EČP", "taxpayer_birth_number"}, + {"datum narození", "taxpayer_birth_date"}, + {"číslo domu", "taxpayer_house_number"}, + {"PSČ", "taxpayer_postal_code"}, + {"obec", "taxpayer_city"}, + } + + var problems []string + for _, req := range required { + if strings.TrimSpace(settings[req.key]) == "" { + problems = append(problems, "chybí "+req.label) + } + } + if dep := strings.TrimSpace(settings["cssz_code"]); dep != "" && !csszDepCodePattern.MatchString(dep) { + problems = append(problems, "kód OSSZ musí mít přesně 3 číslice") + } + if len(problems) == 0 { + return nil + } + return fmt.Errorf("nelze vygenerovat XML přehledu ČSSZ: %s: %w", strings.Join(problems, "; "), domain.ErrInvalidInput) +} + +func formatAmount(amount domain.Amount) string { + return amount.String() +} + +func formatMonthlyAverage(amount domain.Amount, months int64) string { + if months <= 0 { + return "0.00" + } + return domain.Amount(int64(amount) / months).String() +} + +func divideRoundedUp(value int64, divisor int64) int64 { + if divisor <= 0 { + return 0 + } + result := value / divisor + if value%divisor != 0 { + result++ + } + return result +} diff --git a/internal/annualtaxxml/social_insurance_gen_test.go b/internal/annualtaxxml/social_insurance_gen_test.go index fe11e22..845d420 100644 --- a/internal/annualtaxxml/social_insurance_gen_test.go +++ b/internal/annualtaxxml/social_insurance_gen_test.go @@ -1,8 +1,12 @@ package annualtaxxml import ( + "encoding/xml" "errors" + "os" + "os/exec" "path/filepath" + "strings" "testing" "github.com/zajca/zfaktury/internal/domain" @@ -26,18 +30,18 @@ func TestSocialInsuranceXML_CorrectiveFilingType(t *testing.T) { TotalRevenue: domain.NewAmount(900000, 0), TotalExpenses: domain.NewAmount(620000, 0), TaxBase: domain.NewAmount(280000, 0), - AssessmentBase: domain.NewAmount(140000, 0), + AssessmentBase: domain.NewAmount(154000, 0), MinAssessmentBase: domain.NewAmount(131736, 0), - FinalAssessmentBase: domain.NewAmount(140000, 0), + FinalAssessmentBase: domain.NewAmount(154000, 0), InsuranceRate: 292, - TotalInsurance: domain.NewAmount(40880, 0), + TotalInsurance: domain.NewAmount(44968, 0), Prepayments: domain.NewAmount(38088, 0), - Difference: domain.NewAmount(2792, 0), - NewMonthlyPrepay: domain.NewAmount(3407, 0), + Difference: domain.NewAmount(6880, 0), + NewMonthlyPrepay: domain.NewAmount(3748, 0), } settings := map[string]string{ - "cssz_code": "PSSZ", + "cssz_code": "775", "taxpayer_first_name": "Jan", "taxpayer_last_name": "Novak", "taxpayer_birth_number": "8001011234", @@ -65,18 +69,18 @@ func TestSocialInsuranceXML_SupplementaryFilingType(t *testing.T) { TotalRevenue: domain.NewAmount(900000, 0), TotalExpenses: domain.NewAmount(620000, 0), TaxBase: domain.NewAmount(280000, 0), - AssessmentBase: domain.NewAmount(140000, 0), + AssessmentBase: domain.NewAmount(154000, 0), MinAssessmentBase: domain.NewAmount(131736, 0), - FinalAssessmentBase: domain.NewAmount(140000, 0), + FinalAssessmentBase: domain.NewAmount(154000, 0), InsuranceRate: 292, - TotalInsurance: domain.NewAmount(40880, 0), + TotalInsurance: domain.NewAmount(44968, 0), Prepayments: domain.NewAmount(38088, 0), - Difference: domain.NewAmount(2792, 0), - NewMonthlyPrepay: domain.NewAmount(3407, 0), + Difference: domain.NewAmount(6880, 0), + NewMonthlyPrepay: domain.NewAmount(3748, 0), } settings := map[string]string{ - "cssz_code": "PSSZ", + "cssz_code": "775", "taxpayer_first_name": "Jan", "taxpayer_last_name": "Novak", "taxpayer_birth_number": "8001011234", @@ -106,18 +110,18 @@ func TestSocialInsuranceXML_Golden_Regular(t *testing.T) { TotalRevenue: domain.NewAmount(2000000, 0), // 2 000 000 CZK TotalExpenses: domain.NewAmount(1200000, 0), // flat-rate 60% TaxBase: domain.NewAmount(800000, 0), // 2M - 1.2M - AssessmentBase: domain.NewAmount(400000, 0), // 50% of 800 000 + AssessmentBase: domain.NewAmount(440000, 0), // 55% of 800 000 MinAssessmentBase: domain.NewAmount(131736, 0), // minimum for 2025 - FinalAssessmentBase: domain.NewAmount(400000, 0), // max(400000, 131736) + FinalAssessmentBase: domain.NewAmount(440000, 0), // max(440000, 131736) InsuranceRate: 292, // 29.2% - TotalInsurance: domain.NewAmount(116800, 0), // 400000 * 0.292 + TotalInsurance: domain.NewAmount(128480, 0), // 440000 * 0.292 Prepayments: domain.NewAmount(38088, 0), // 3174/month * 12 - Difference: domain.NewAmount(78712, 0), // 116800 - 38088 - NewMonthlyPrepay: domain.NewAmount(9734, 0), // new monthly prepayment + Difference: domain.NewAmount(90392, 0), // 128480 - 38088 + NewMonthlyPrepay: domain.NewAmount(10707, 0), // new monthly prepayment } settings := map[string]string{ - "cssz_code": "PSSZ", + "cssz_code": "775", "taxpayer_first_name": "Jan", "taxpayer_last_name": "Novak", "taxpayer_birth_number": "8001011234", @@ -148,18 +152,18 @@ func TestSocialInsuranceXML_Golden_ActualExpenses(t *testing.T) { TotalRevenue: domain.NewAmount(900000, 0), // 900 000 CZK TotalExpenses: domain.NewAmount(620000, 0), // actual expenses TaxBase: domain.NewAmount(280000, 0), // 900k - 620k - AssessmentBase: domain.NewAmount(140000, 0), // 50% of 280 000 + AssessmentBase: domain.NewAmount(154000, 0), // 55% of 280 000 MinAssessmentBase: domain.NewAmount(131736, 0), // minimum for 2025 - FinalAssessmentBase: domain.NewAmount(140000, 0), // max(140000, 131736) + FinalAssessmentBase: domain.NewAmount(154000, 0), // max(154000, 131736) InsuranceRate: 292, // 29.2% - TotalInsurance: domain.NewAmount(40880, 0), // 140000 * 0.292 + TotalInsurance: domain.NewAmount(44968, 0), // 154000 * 0.292 Prepayments: domain.NewAmount(38088, 0), // 3174/month * 12 - Difference: domain.NewAmount(2792, 0), // 40880 - 38088 - NewMonthlyPrepay: domain.NewAmount(3407, 0), // new monthly prepayment + Difference: domain.NewAmount(6880, 0), // 44968 - 38088 + NewMonthlyPrepay: domain.NewAmount(3748, 0), // new monthly prepayment } settings := map[string]string{ - "cssz_code": "OSSZ Brno-venkov", + "cssz_code": "775", "taxpayer_first_name": "Eva", "taxpayer_last_name": "Svobodova", "taxpayer_birth_number": "9055121234", @@ -180,3 +184,127 @@ func TestSocialInsuranceXML_Golden_ActualExpenses(t *testing.T) { goldenPath := filepath.Join("testdata", "social_insurance_actual_expenses.golden.xml") testutil.AssertGolden(t, goldenPath, xmlData) } + +func TestSocialInsuranceXML_SemanticFieldsFromCSSZSpec(t *testing.T) { + sio := &domain.SocialInsuranceOverview{ + Year: 2025, + FilingType: domain.FilingTypeRegular, + TaxBase: domain.NewAmount(620339, 20), + AssessmentBase: domain.NewAmount(341187, 0), + FinalAssessmentBase: domain.NewAmount(341187, 0), + TotalInsurance: domain.NewAmount(99627, 0), + Prepayments: domain.NewAmount(86991, 0), + Difference: domain.NewAmount(12636, 0), + NewMonthlyPrepay: domain.NewAmount(8303, 0), + } + settings := validSocialInsuranceSettings() + settings["cssz_variable_symbol"] = "75660248" + settings["taxpayer_title"] = "Ing." + settings["taxpayer_databox_id"] = "7rgmm99" + settings["sickness_insurance_monthly"] = "768" + + xmlData, err := GenerateSocialInsuranceXML(sio, settings) + if err != nil { + t.Fatalf("GenerateSocialInsuranceXML: %v", err) + } + + var doc OSVC + if err := xml.Unmarshal(xmlData, &doc); err != nil { + t.Fatalf("unmarshal generated XML: %v", err) + } + + if doc.Prehled.For != "prehledosvc" { + t.Errorf("prehledosvc@for = %q, want prehledosvc", doc.Prehled.For) + } + if doc.Prehled.Dep != "775" { + t.Errorf("prehledosvc@dep = %q, want 775", doc.Prehled.Dep) + } + if doc.Prehled.VSDP != "75660248" { + t.Errorf("prehledosvc@vsdp = %q, want 75660248", doc.Prehled.VSDP) + } + if doc.Prehled.Client.Hlavc.M13 != "A" || doc.Prehled.Client.Hlavc.M1 != "" { + t.Errorf("hlavc flags = m1:%q m13:%q, want full-year marker only in m13", doc.Prehled.Client.Hlavc.M1, doc.Prehled.Client.Hlavc.M13) + } + if doc.Prehled.PVV.Pri != "620339.20" { + t.Errorf("pvv@pri = %q, want 620339.20", doc.Prehled.PVV.Pri) + } + if doc.Prehled.PVV.Mesc.H != "12" || doc.Prehled.PVV.Mesv.H != "12" { + t.Errorf("pvv months = mesc:%q mesv:%q, want 12/12", doc.Prehled.PVV.Mesc.H, doc.Prehled.PVV.Mesv.H) + } + if doc.Prehled.PVV.Mesp != "51694.93" { + t.Errorf("mesp = %q, want 51694.93", doc.Prehled.PVV.Mesp) + } + if doc.Prehled.PVV.VVZ.H != "341187" || doc.Prehled.PVV.Poj != "99627" || doc.Prehled.PVV.Ned != "12636" { + t.Errorf("pvv totals = vvz:%q poj:%q ned:%q, want 341187/99627/12636", doc.Prehled.PVV.VVZ.H, doc.Prehled.PVV.Poj, doc.Prehled.PVV.Ned) + } + if doc.Prehled.Zal.Ved != "H" || doc.Prehled.Zal.VZ != "28433" || doc.Prehled.Zal.DP != "8303" || doc.Prehled.Zal.NP != "768" { + t.Errorf("zal attrs = ved:%q vz:%q dp:%q np:%q, want H/28433/8303/768", doc.Prehled.Zal.Ved, doc.Prehled.Zal.VZ, doc.Prehled.Zal.DP, doc.Prehled.Zal.NP) + } +} + +func TestSocialInsuranceXML_RequiresCSSZSpecFields(t *testing.T) { + _, err := GenerateSocialInsuranceXML(&domain.SocialInsuranceOverview{Year: 2025}, map[string]string{ + "cssz_code": "PSSZ", + }) + if err == nil { + t.Fatal("expected validation error") + } + if !errors.Is(err, domain.ErrInvalidInput) { + t.Errorf("expected ErrInvalidInput, got: %v", err) + } + for _, want := range []string{"kód OSSZ", "jméno", "rodné číslo", "PSČ"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("expected error to contain %q, got: %v", want, err) + } + } +} + +func TestSocialInsuranceXML_ValidatesAgainstOSVC25XSD(t *testing.T) { + xmllint, err := exec.LookPath("xmllint") + if err != nil { + t.Skip("xmllint not available; run with libxml2 installed to perform XSD validation") + } + + sio := &domain.SocialInsuranceOverview{ + Year: 2025, + FilingType: domain.FilingTypeRegular, + TaxBase: domain.NewAmount(620339, 20), + AssessmentBase: domain.NewAmount(341187, 0), + FinalAssessmentBase: domain.NewAmount(341187, 0), + TotalInsurance: domain.NewAmount(99627, 0), + Prepayments: domain.NewAmount(86991, 0), + Difference: domain.NewAmount(12636, 0), + NewMonthlyPrepay: domain.NewAmount(8303, 0), + } + xmlData, err := GenerateSocialInsuranceXML(sio, validSocialInsuranceSettings()) + if err != nil { + t.Fatalf("GenerateSocialInsuranceXML: %v", err) + } + + xmlPath := filepath.Join(t.TempDir(), "osvc25.xml") + if err := os.WriteFile(xmlPath, xmlData, 0o600); err != nil { + t.Fatalf("write generated XML: %v", err) + } + xsdPath := filepath.Join("..", "..", "docs", "xml-schemas", "cssz", "OSVC25.xsd") + cmd := exec.Command(xmllint, "--noout", "--schema", xsdPath, xmlPath) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("xmllint XSD validation failed: %v\n%s", err, out) + } +} + +func validSocialInsuranceSettings() map[string]string { + return map[string]string{ + "cssz_code": "775", + "taxpayer_first_name": "Martin", + "taxpayer_last_name": "Zajic", + "taxpayer_birth_number": "8905244997", + "taxpayer_birth_date": "1989-05-24", + "taxpayer_street": "Ludkovice", + "taxpayer_house_number": "189", + "taxpayer_postal_code": "76341", + "taxpayer_city": "Ludkovice", + "taxpayer_email": "ja@example.test", + "taxpayer_phone": "776598983", + } +} diff --git a/internal/annualtaxxml/social_insurance_types.go b/internal/annualtaxxml/social_insurance_types.go index 39782c2..a798332 100644 --- a/internal/annualtaxxml/social_insurance_types.go +++ b/internal/annualtaxxml/social_insurance_types.go @@ -20,7 +20,7 @@ type Vendor struct { // Sender contains notification and protocol settings. type Sender struct { - EmailNotifikace string `xml:"EmailNotifikace,attr"` + EmailNotifikace string `xml:"EmailNotifikace,attr,omitempty"` ISDSreport string `xml:"ISDSreport,attr"` VerzeProtokolu string `xml:"VerzeProtokolu,attr"` } @@ -31,8 +31,8 @@ type PrehledOSVC struct { Dep string `xml:"dep,attr"` Rok string `xml:"rok,attr"` Typ string `xml:"typ,attr"` - VSDP string `xml:"vsdp,attr"` - Dat string `xml:"dat,attr"` + VSDP string `xml:"vsdp,attr,omitempty"` + Dat string `xml:"dat,attr,omitempty"` Client Client `xml:"client"` PVV PVV `xml:"pvv"` @@ -40,8 +40,10 @@ type PrehledOSVC struct { Zal Zal `xml:"zal"` Pre Pre `xml:"pre"` Prizn Prizn `xml:"prizn"` + Opr Opr `xml:"opr"` Spo Spo `xml:"spo"` DatEl DatEl `xml:"dat"` + Prilo Prilo `xml:"prilo"` } // Client contains taxpayer personal information. @@ -57,13 +59,14 @@ type Client struct { Vedc Vedc `xml:"vedc"` Narok MonthFlags `xml:"narok"` Sleva MonthFlags `xml:"sleva"` + Vzpm string `xml:"vzpm"` } // ClientName holds first/last name and title. type ClientName struct { Fir string `xml:"fir,attr"` Sur string `xml:"sur,attr"` - Tit string `xml:"tit,attr"` + Tit string `xml:"tit,attr,omitempty"` } // ClientBirth holds birth number and date. @@ -74,11 +77,11 @@ type ClientBirth struct { // Address holds a Czech postal address. type Address struct { - Cit string `xml:"cit,attr"` - Cnt string `xml:"cnt,attr"` - Num string `xml:"num,attr"` - Pnu string `xml:"pnu,attr"` - Str string `xml:"str,attr"` + Cit string `xml:"cit,attr,omitempty"` + Cnt string `xml:"cnt,attr,omitempty"` + Num string `xml:"num,attr,omitempty"` + Pnu string `xml:"pnu,attr,omitempty"` + Str string `xml:"str,attr,omitempty"` } // MonthFlags represents 13 monthly flag fields (m1-m13). @@ -151,23 +154,23 @@ type PVV struct { // HVPair holds an h/v attribute pair used in PVV fields. type HVPair struct { H string `xml:"h,attr"` - V string `xml:"v,attr"` + V string `xml:"v,attr,omitempty"` } // Zal contains advance payment settings. type Zal struct { - Ved string `xml:"ved,attr"` - Pau string `xml:"pau,attr"` - VZ string `xml:"vz,attr"` - DP string `xml:"dp,attr"` - NP string `xml:"np,attr"` - Duch string `xml:"duch,attr"` + Ved string `xml:"ved,attr,omitempty"` + Pau string `xml:"pau,attr,omitempty"` + VZ string `xml:"vz,attr,omitempty"` + DP string `xml:"dp,attr,omitempty"` + NP string `xml:"np,attr,omitempty"` + Duch string `xml:"duch,attr,omitempty"` } // Pre contains overpayment return information. type Pre struct { Vra string `xml:"vra,attr"` - Kam string `xml:"kam,attr"` + Kam string `xml:"kam,attr,omitempty"` Rok string `xml:"rok"` IBAN string `xml:"iban"` BS PreBS `xml:"bs"` @@ -176,38 +179,51 @@ type Pre struct { // PreBS contains bank account details for overpayment return. type PreBS struct { - PU string `xml:"pu,attr"` - CU string `xml:"cu,attr"` - KB string `xml:"kb,attr"` - SS string `xml:"ss,attr"` - VS string `xml:"vs,attr"` + PU string `xml:"pu,attr,omitempty"` + CU string `xml:"cu,attr,omitempty"` + KB string `xml:"kb,attr,omitempty"` + SS string `xml:"ss,attr,omitempty"` + VS string `xml:"vs,attr,omitempty"` } // Prizn contains declaration flags. type Prizn struct { - Pau string `xml:"pau"` + Pau string `xml:"pau,omitempty"` Pov string `xml:"pov"` Elektr string `xml:"elektr"` - Por string `xml:"por"` + Por string `xml:"por,omitempty"` Meldat string `xml:"meldat"` } // Spo contains spouse information. type Spo struct { - Bno string `xml:"bno,attr"` - Den string `xml:"den,attr"` + Bno string `xml:"bno,attr,omitempty"` + Den string `xml:"den,attr,omitempty"` Name SpoName `xml:"name"` Adr Address `xml:"adr"` } // SpoName holds spouse name fields. type SpoName struct { - Sur string `xml:"sur,attr"` - Fir string `xml:"fir,attr"` - Tit string `xml:"tit,attr"` + Sur string `xml:"sur,attr,omitempty"` + Fir string `xml:"fir,attr,omitempty"` + Tit string `xml:"tit,attr,omitempty"` } // DatEl holds the filing date element. type DatEl struct { - Dre string `xml:"dre,attr"` + Dre string `xml:"dre,attr,omitempty"` +} + +// Opr contains corrective overview details. +type Opr struct { + Datopr string `xml:"datopr,attr,omitempty"` + Duvod string `xml:"duvod,attr,omitempty"` +} + +// Prilo contains attachment metadata. +type Prilo struct { + Coun string `xml:"coun,attr"` + Plnamoc string `xml:"plnamoc,attr"` + Jina string `xml:"jina,attr"` } diff --git a/internal/annualtaxxml/testdata/social_insurance_actual_expenses.golden.xml b/internal/annualtaxxml/testdata/social_insurance_actual_expenses.golden.xml index f86643d..d77b86c 100644 --- a/internal/annualtaxxml/testdata/social_insurance_actual_expenses.golden.xml +++ b/internal/annualtaxxml/testdata/social_insurance_actual_expenses.golden.xml @@ -1,10 +1,10 @@ - - + + - + @@ -12,7 +12,7 @@ H - A + @@ -24,7 +24,7 @@ - + A @@ -63,7 +63,7 @@ - n + @@ -77,44 +77,45 @@ + - - - - 1 - - + + + + 23333.33 + + - 131736 - 140000 + 154000 + 154000 - 140000 - 140000 - 40880 - - + 154000 + 154000 + 44968 + 0 + 44968 38088 - 2792 + 6880 - -
+    
+    
       
       
-      
-      
+      
+      
     
- H A - N - N + A - - - + + + + - + + \ No newline at end of file diff --git a/internal/annualtaxxml/testdata/social_insurance_corrective.golden.xml b/internal/annualtaxxml/testdata/social_insurance_corrective.golden.xml index 5d9375d..2a18ef8 100644 --- a/internal/annualtaxxml/testdata/social_insurance_corrective.golden.xml +++ b/internal/annualtaxxml/testdata/social_insurance_corrective.golden.xml @@ -1,10 +1,10 @@ - - + + - + @@ -12,7 +12,7 @@ H - A + @@ -24,7 +24,7 @@ - + A @@ -63,7 +63,7 @@ - n + @@ -77,44 +77,45 @@ + - - - - 1 - - + + + + 23333.33 + + - 131736 - 140000 + 154000 + 154000 - 140000 - 140000 - 40880 - - + 154000 + 154000 + 44968 + 0 + 44968 38088 - 2792 + 6880 - -
+    
+    
       
       
-      
-      
+      
+      
     
- H A - N - N + A - - - + + + + - + + \ No newline at end of file diff --git a/internal/annualtaxxml/testdata/social_insurance_regular.golden.xml b/internal/annualtaxxml/testdata/social_insurance_regular.golden.xml index 9575b82..b40cccd 100644 --- a/internal/annualtaxxml/testdata/social_insurance_regular.golden.xml +++ b/internal/annualtaxxml/testdata/social_insurance_regular.golden.xml @@ -1,10 +1,10 @@ - - + + - + @@ -12,7 +12,7 @@ H - A + @@ -24,7 +24,7 @@ - + A @@ -63,7 +63,7 @@ - n + @@ -77,44 +77,45 @@ + - - - - 1 - - + + + + 66666.66 + + - 131736 - 400000 + 440000 + 440000 - 400000 - 400000 - 116800 - - + 440000 + 440000 + 128480 + 0 + 128480 38088 - 78712 + 90392 - -
+    
+    
       
       
-      
-      
+      
+      
     
- H A - N - N + A - - - + + + + - + + \ No newline at end of file diff --git a/internal/annualtaxxml/testdata/social_insurance_supplementary.golden.xml b/internal/annualtaxxml/testdata/social_insurance_supplementary.golden.xml index c1a6ab6..987ab18 100644 --- a/internal/annualtaxxml/testdata/social_insurance_supplementary.golden.xml +++ b/internal/annualtaxxml/testdata/social_insurance_supplementary.golden.xml @@ -1,10 +1,10 @@ - - + + - + @@ -12,7 +12,7 @@ H - A + @@ -24,7 +24,7 @@ - + A @@ -63,7 +63,7 @@ - n + @@ -77,44 +77,45 @@ + - - - - 1 - - + + + + 23333.33 + + - 131736 - 140000 + 154000 + 154000 - 140000 - 140000 - 40880 - - + 154000 + 154000 + 44968 + 0 + 44968 38088 - 2792 + 6880 - -
+    
+    
       
       
-      
-      
+      
+      
     
- H A - N - N + A - - - + + + + - + + \ No newline at end of file diff --git a/internal/calc/insurance.go b/internal/calc/insurance.go index 704133d..af3bd9e 100644 --- a/internal/calc/insurance.go +++ b/internal/calc/insurance.go @@ -7,8 +7,11 @@ type InsuranceInput struct { Revenue domain.Amount UsedExpenses domain.Amount // already resolved (flat-rate or actual) MinMonthlyBase domain.Amount // e.g. constants.SocialMinMonthly or HealthMinMonthly - RatePermille int // 292 for social, 135 for health - Prepayments domain.Amount + // AssessmentBasePermille is the share of tax base used as the assessment + // base. Zero keeps the historical/default 50% behavior. + AssessmentBasePermille int + RatePermille int // 292 for social, 135 for health + Prepayments domain.Amount } // InsuranceResult holds the computed insurance values. @@ -30,8 +33,16 @@ func CalculateInsurance(input InsuranceInput) InsuranceResult { taxBase = 0 } - // assessmentBase = taxBase / 2 (integer division in halere). - assessmentBase := taxBase / 2 + assessmentBasePermille := input.AssessmentBasePermille + if assessmentBasePermille == 0 { + assessmentBasePermille = 500 + } + + assessmentBase := taxBase * int64(assessmentBasePermille) / 1000 + if input.AssessmentBasePermille != 0 { + // CSSZ expects the computed assessment base rounded up to whole CZK. + assessmentBase = ceilToWholeCZK(assessmentBase) + } // minAssessmentBase = minMonthlyBase * 12. minAssessmentBase := int64(input.MinMonthlyBase) * 12 @@ -42,8 +53,11 @@ func CalculateInsurance(input InsuranceInput) InsuranceResult { finalBase = minAssessmentBase } - // totalInsurance = finalBase * ratePermille / 1000. totalInsurance := finalBase * int64(input.RatePermille) / 1000 + if input.AssessmentBasePermille != 0 { + // CSSZ form line D32.1 is rounded up to whole CZK. + totalInsurance = ceilToWholeCZK(totalInsurance) + } // difference = totalInsurance - prepayments. difference := totalInsurance - int64(input.Prepayments) @@ -66,6 +80,16 @@ func CalculateInsurance(input InsuranceInput) InsuranceResult { } } +func ceilToWholeCZK(halere int64) int64 { + if halere <= 0 { + return halere + } + if halere%100 == 0 { + return halere + } + return ((halere / 100) + 1) * 100 +} + // ResolveUsedExpenses determines the expenses to use in tax calculations. // If flatRatePercent > 0, it computes revenue * flatRatePercent/100, applies the // cap from caps map if one exists for the given percent, and returns the result. diff --git a/internal/calc/insurance_test.go b/internal/calc/insurance_test.go index 0947ce1..efaaefb 100644 --- a/internal/calc/insurance_test.go +++ b/internal/calc/insurance_test.go @@ -260,3 +260,30 @@ func TestCalculateInsuranceMonthlyRounding(t *testing.T) { }) } } + +func TestCalculateInsuranceSocialAssessmentBase55Percent(t *testing.T) { + got := CalculateInsurance(InsuranceInput{ + Revenue: domain.NewAmount(1_550_848, 0), + UsedExpenses: domain.NewAmount(930_508, 80), + MinMonthlyBase: domain.NewAmount(16_295, 0), + AssessmentBasePermille: 550, + RatePermille: 292, + Prepayments: domain.NewAmount(86_991, 0), + }) + + if got.TaxBase != domain.NewAmount(620_339, 20) { + t.Errorf("TaxBase = %s, want 620339.20", got.TaxBase) + } + if got.AssessmentBase != domain.NewAmount(341_187, 0) { + t.Errorf("AssessmentBase = %s, want 341187.00", got.AssessmentBase) + } + if got.FinalAssessmentBase != domain.NewAmount(341_187, 0) { + t.Errorf("FinalAssessmentBase = %s, want 341187.00", got.FinalAssessmentBase) + } + if got.TotalInsurance != domain.NewAmount(99_627, 0) { + t.Errorf("TotalInsurance = %s, want 99627.00", got.TotalInsurance) + } + if got.NewMonthlyPrepay != domain.NewAmount(8_303, 0) { + t.Errorf("NewMonthlyPrepay = %s, want 8303.00", got.NewMonthlyPrepay) + } +} diff --git a/internal/handler/social_insurance_handler_test.go b/internal/handler/social_insurance_handler_test.go index 887e071..31e7d1f 100644 --- a/internal/handler/social_insurance_handler_test.go +++ b/internal/handler/social_insurance_handler_test.go @@ -2,6 +2,7 @@ package handler import ( "bytes" + "context" "database/sql" "encoding/json" "fmt" @@ -22,6 +23,22 @@ func setupSocialInsuranceRouter(t *testing.T) (*chi.Mux, *sql.DB) { invoiceRepo := repository.NewInvoiceRepository(db) expenseRepo := repository.NewExpenseRepository(db) settingsRepo := repository.NewSettingsRepository(db) + if err := settingsRepo.SetBulk(context.Background(), map[string]string{ + service.SettingCSSZCode: "775", + service.SettingFirstName: "Jan", + service.SettingLastName: "Novak", + service.SettingBirthNumber: "8001011234", + service.SettingBirthDate: "1980-01-01", + service.SettingStreet: "Hlavni", + service.SettingHouseNumber: "42", + service.SettingCity: "Praha", + service.SettingZIP: "11000", + service.SettingEmail: "jan@example.test", + service.SettingPhone: "777123456", + service.SettingCSSZVariableSym: "12345678", + }); err != nil { + t.Fatalf("seed settings: %v", err) + } taxYearSettingsRepo := repository.NewTaxYearSettingsRepository(db) taxPrepaymentRepo := repository.NewTaxPrepaymentRepository(db) diff --git a/internal/service/settings_svc.go b/internal/service/settings_svc.go index 3978cfd..3785db9 100644 --- a/internal/service/settings_svc.go +++ b/internal/service/settings_svc.go @@ -36,11 +36,14 @@ const ( SettingHouseNumber = "house_number" SettingBirthNumber = "birth_number" SettingBirthDate = "birth_date" + SettingTitle = "title" + SettingDataBoxID = "databox_id" // Office codes (global, rarely change). SettingHealthInsCode = "health_insurance_code" SettingFinancniUradCode = "financni_urad_code" SettingCSSZCode = "cssz_code" + SettingCSSZVariableSym = "cssz_variable_symbol" SettingUFOCode = "c_ufo" SettingPracUFO = "c_pracufo" SettingOKEC = "c_okec" @@ -228,9 +231,12 @@ var knownKeys = map[string]bool{ SettingHouseNumber: true, SettingBirthNumber: true, SettingBirthDate: true, + SettingTitle: true, + SettingDataBoxID: true, SettingHealthInsCode: true, SettingFinancniUradCode: true, SettingCSSZCode: true, + SettingCSSZVariableSym: true, SettingUFOCode: true, SettingPracUFO: true, SettingOKEC: true, diff --git a/internal/service/social_insurance_svc.go b/internal/service/social_insurance_svc.go index a2eccd7..974d8be 100644 --- a/internal/service/social_insurance_svc.go +++ b/internal/service/social_insurance_svc.go @@ -179,11 +179,12 @@ func (s *SocialInsuranceService) Recalculate(ctx context.Context, id int64) (*do // Pure calculation. insResult := calc.CalculateInsurance(calc.InsuranceInput{ - Revenue: annualBase.Revenue, - UsedExpenses: usedExpenses, - MinMonthlyBase: constants.SocialMinMonthly, - RatePermille: constants.SocialRate, - Prepayments: socialTotal, + Revenue: annualBase.Revenue, + UsedExpenses: usedExpenses, + MinMonthlyBase: constants.SocialMinMonthly, + AssessmentBasePermille: 550, + RatePermille: constants.SocialRate, + Prepayments: socialTotal, }) sio.TaxBase = insResult.TaxBase @@ -225,12 +226,16 @@ func (s *SocialInsuranceService) GenerateXML(ctx context.Context, id int64) (*do settingKeyMap := map[string]string{ "first_name": "taxpayer_first_name", "last_name": "taxpayer_last_name", + "title": "taxpayer_title", "birth_number": "taxpayer_birth_number", "birth_date": "taxpayer_birth_date", "street": "taxpayer_street", "house_number": "taxpayer_house_number", "city": "taxpayer_city", "zip": "taxpayer_postal_code", + "email": "taxpayer_email", + "phone": "taxpayer_phone", + "databox_id": "taxpayer_databox_id", } for srcKey, dstKey := range settingKeyMap { if val, err := s.settingsRepo.Get(ctx, srcKey); err == nil { @@ -240,6 +245,9 @@ func (s *SocialInsuranceService) GenerateXML(ctx context.Context, id int64) (*do if val, err := s.settingsRepo.Get(ctx, "cssz_code"); err == nil { settings["cssz_code"] = val } + if val, err := s.settingsRepo.Get(ctx, "cssz_variable_symbol"); err == nil { + settings["cssz_variable_symbol"] = val + } // Read flat_rate_percent from tax year settings for XML generation. tys, tysErr := s.taxYearSettingsRepo.GetByYear(ctx, sio.Year) if tysErr == nil && tys.FlatRatePercent > 0 { diff --git a/internal/service/social_insurance_svc_test.go b/internal/service/social_insurance_svc_test.go index 796136e..91d50cf 100644 --- a/internal/service/social_insurance_svc_test.go +++ b/internal/service/social_insurance_svc_test.go @@ -332,8 +332,8 @@ func TestSocialInsuranceService_Recalculate_WithInvoice(t *testing.T) { t.Errorf("TaxBase = %d, want %d", result.TaxBase, expectedTaxBase) } - // AssessmentBase = TaxBase / 2. - expectedAssessment := domain.Amount(int64(expectedTaxBase) / 2) + // CSSZ 2025 assessment base = 55% of tax base. + expectedAssessment := domain.Amount(int64(expectedTaxBase) * 550 / 1000) if result.AssessmentBase != expectedAssessment { t.Errorf("AssessmentBase = %d, want %d", result.AssessmentBase, expectedAssessment) } @@ -380,8 +380,8 @@ func TestSocialInsuranceService_Recalculate_MinAssessmentBase(t *testing.T) { t.Errorf("FinalAssessmentBase = %d, want %d (should use minimum)", result.FinalAssessmentBase, expectedMinBase) } - // TotalInsurance = minBase * rate / 1000. - expectedInsurance := domain.Amount(int64(expectedMinBase) * int64(constants.SocialRate) / 1000) + // TotalInsurance = minBase * rate / 1000, rounded up to whole CZK for CSSZ. + expectedInsurance := domain.Amount(((int64(expectedMinBase)*int64(constants.SocialRate)/1000 + 99) / 100) * 100) if result.TotalInsurance != expectedInsurance { t.Errorf("TotalInsurance = %d, want %d", result.TotalInsurance, expectedInsurance) }