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"
/>
-
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)
}