From 796692f38e2df3dd0e9eb0bc2e81ae349c95b96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luka=20De=C5=BEulovi=C4=87?= Date: Tue, 24 Sep 2024 12:28:35 +0200 Subject: [PATCH] Refactor ciscomm.go and dsignandverify.go for code cleanup and optimization Add InvoiceRequest method --- canonicalization_test.go | 151 +++++++++++++++++++++++++++++++++++++++ ciscomm.go | 1 - dsignandverify.go | 2 +- fiskal-schema.go | 1 + fiskalhr.go | 105 +++++++++++++++++++++++++++ fiskalhr_test.go | 50 ++++--------- 6 files changed, 270 insertions(+), 40 deletions(-) create mode 100644 canonicalization_test.go diff --git a/canonicalization_test.go b/canonicalization_test.go new file mode 100644 index 0000000..e0d40e1 --- /dev/null +++ b/canonicalization_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// This file is adapted from the github.com/russellhaering/goxmldsig project. +package fiskalhrgo + +import ( + "strings" + "testing" + + "github.com/beevik/etree" + "github.com/stretchr/testify/require" +) + +const ( + assertion = `https://saml2.test.astuart.co/sso/saml2urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport` + c14n11 = `https://saml2.test.astuart.co/sso/saml2urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport` + assertionC14ned = `https://saml2.test.astuart.co/sso/saml2urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport` + c14n11Comment = `https://saml2.test.astuart.co/sso/saml2urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport` + assertionC14nedComment = `https://saml2.test.astuart.co/sso/saml2urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport` +) + +const ( + xmldoc = `` + xmldocC14N10ExclusiveCanonicalized = `` + xmldocC14N11Canonicalized = `` +) + +func runCanonicalizationTest(t *testing.T, canonicalizer Canonicalizer, xmlstr string, canonicalXmlstr string) { + raw := etree.NewDocument() + err := raw.ReadFromString(xmlstr) + require.NoError(t, err) + + canonicalized, err := canonicalizer.Canonicalize(raw.Root()) + require.NoError(t, err) + require.Equal(t, canonicalXmlstr, string(canonicalized)) +} + +func TestExcC14N10(t *testing.T) { + runCanonicalizationTest(t, MakeC14N10ExclusiveCanonicalizerWithPrefixList(""), assertion, assertionC14ned) +} + +func TestExcC14N10WithComments(t *testing.T) { + runCanonicalizationTest(t, MakeC14N10ExclusiveWithCommentsCanonicalizerWithPrefixList(""), assertion, assertionC14nedComment) +} + +func TestC14N11(t *testing.T) { + runCanonicalizationTest(t, MakeC14N11Canonicalizer(), assertion, c14n11) +} + +func TestC14N11WithComments(t *testing.T) { + runCanonicalizationTest(t, MakeC14N11WithCommentsCanonicalizer(), assertion, c14n11Comment) +} + +func TestXmldocC14N10Exclusive(t *testing.T) { + runCanonicalizationTest(t, MakeC14N10ExclusiveCanonicalizerWithPrefixList(""), xmldoc, xmldocC14N10ExclusiveCanonicalized) +} + +func TestXmldocC14N11(t *testing.T) { + runCanonicalizationTest(t, MakeC14N11Canonicalizer(), xmldoc, xmldocC14N11Canonicalized) +} + +func TestNestedExcC14N11(t *testing.T) { + input := `` + expected := `` + runCanonicalizationTest(t, MakeC14N11Canonicalizer(), input, expected) +} + +func TestExcC14nDefaultNamespace(t *testing.T) { + input := `` + expected := `` + runCanonicalizationTest(t, MakeC14N10ExclusiveCanonicalizerWithPrefixList(""), input, expected) +} + +func TestExcC14nWithPrefixList(t *testing.T) { + input := `` + expected := `` + canonicalizer := MakeC14N10ExclusiveCanonicalizerWithPrefixList("xs") + runCanonicalizationTest(t, canonicalizer, input, expected) +} + +func TestExcC14nRedeclareDefaultNamespace(t *testing.T) { + input := `` + expected := `` + canonicalizer := MakeC14N10ExclusiveCanonicalizerWithPrefixList("") + runCanonicalizationTest(t, canonicalizer, input, expected) +} + +func TestC14N10RecCanonicalizer(t *testing.T) { + // From https://www.w3.org/TR/2001/REC-xml-c14n-20010315#Example-SETags + input := ` + + + + + + + + + + + + +` + expected := ` + + + + + + + + + + + + +` + + canonicalizer := MakeC14N10RecCanonicalizer() + runCanonicalizationTest(t, canonicalizer, input, expected) +} + +func TestC14N10RecCanonicalizerWithNamespaceInheritance(t *testing.T) { + input := ` + + Hello, World! + + ` + + expected := ` + Hello, World! + ` + + doc := etree.NewDocument() + if err := doc.ReadFromString(input); err != nil { + t.Fatalf("Error parsing input XML: %v", err) + } + + childElement := doc.FindElement(".//ns2:ChildElement") + if childElement == nil { + t.Fatal("Error: childElement not found") + } + + canonicalizer := MakeC14N10RecCanonicalizer() + canonicalized, err := canonicalizer.Canonicalize(childElement) + require.NoError(t, err) + require.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(canonicalized))) + +} diff --git a/ciscomm.go b/ciscomm.go index fd41bf2..a24837e 100644 --- a/ciscomm.go +++ b/ciscomm.go @@ -70,7 +70,6 @@ func (fe *FiskalEntity) GetResponse(xmlPayload []byte, sign bool) ([]byte, int, return nil, 0, fmt.Errorf("failed to sign XML: %w", err) } xmlPayload = signedXML - fmt.Println(string(xmlPayload)) } // Prepare the SOAP envelope with the payload diff --git a/dsignandverify.go b/dsignandverify.go index f8eb71b..4b3571c 100644 --- a/dsignandverify.go +++ b/dsignandverify.go @@ -28,7 +28,7 @@ func docC14N10(xmlData string) ([]byte, error) { return nil, fmt.Errorf("failed to parse XML: %v", err) } - // Use the Canonical XML 1.0 algorithm from goxmldsig + // Use the Canonical XML 1.0 algorithm canonicalizer := MakeC14N10RecCanonicalizer() // Without comments canonicalizedXML, err := canonicalizer.Canonicalize(doc.Root()) if err != nil { diff --git a/fiskal-schema.go b/fiskal-schema.go index e3d3565..10dcfc2 100644 --- a/fiskal-schema.go +++ b/fiskal-schema.go @@ -130,6 +130,7 @@ type ZaglavljeOdgovorType struct { // RacunType represents the invoice type with various details required for fiscalization. type RacunType struct { + XMLName xml.Name `xml:"tns:Racun"` Oib string `xml:"tns:Oib"` USustPdv bool `xml:"tns:USustPdv"` DatVrijeme string `xml:"tns:DatVrijeme"` diff --git a/fiskalhr.go b/fiskalhr.go index eed66e1..19bd1f8 100644 --- a/fiskalhr.go +++ b/fiskalhr.go @@ -13,6 +13,7 @@ import ( "errors" "fmt" "strconv" + "strings" "time" ) @@ -319,3 +320,107 @@ func (fe *FiskalEntity) PingCIS() error { } return nil } + +// InvoiceRequest sends an invoice request to the CIS (Croatian Fiscalization System) and processes the response. +// +// This function performs the following steps: +// 1. Minimally validates the provided invoice for required fields +// (any business logic and math is the responsibility of the invoicing application using the library) +// PLEASE NOTE: the CIS also don't do any extensive validation of the invoice, only basic checks. +// so you could get a JIR back even if the invoice is not correct. +// But if you do that you can have problems later with inspecions or periodic CIS checks of the data. +// The library will send the data as is to the CIS. +// So please validate and chek the invoice data acording to you buinies logic +// before sending it to the CIS. +// 2. Sends the XML request to the CIS and receives the response. +// 3. Unmarshals the response XML to extract the response data. +// 4. Checks for errors in the response and aggregates them if any are found. +// 5. Returns the JIR (Unique Invoice Identifier) if the request was successful. +// +// Parameters: +// - invoice: A pointer to a RacunType struct representing the invoice to be sent. +// +// Returns: +// - A string representing the JIR (Unique Invoice Identifier) if the request was successful. +// - A string representing the ZKI (Protection Code of the Issuer) from the invoice. +// - An error if any issues occurred during the process. +// +// Possible errors: +// - If the invoice is nil or something is invalid (only basic checks). +// - If the SpecNamj field of the invoice is not empty. +// - If the ZastKod field of the invoice is empty. +// - If there is an error marshalling the request to XML. +// - If there is an error making the request to the CIS. +// - If there is an error unmarshalling the response XML. +// - If the IdPoruke in the response does not match the request. +// - If the response status is not 200 and there are errors in the response. +// - If the JIR in the response is empty. +// - If an unexpected error occurs. +func (fe *FiskalEntity) InvoiceRequest(invoice *RacunType) (string, string, error) { + + //some basic tests for invoice + if invoice == nil { + return "", "", errors.New("invoice is nil") + } + + if invoice.SpecNamj != "" { + return "", "", errors.New("invoice SpecNamj must be empty") + } + + if invoice.ZastKod == "" { + return "", "", errors.New("invoice ZKI (Zastitni Kod Izdavatelja) must be set") + } + + //Combine with zahtjev for final XML + zahtjev := RacunZahtjev{ + Zaglavlje: NewFiskalHeader(), + Racun: invoice, + Xmlns: DefaultNamespace, + IdAttr: generateUniqueID(), + } + + // Marshal the RacunZahtjev to XML + xmlData, err := xml.MarshalIndent(zahtjev, "", " ") + if err != nil { + return "", invoice.ZastKod, fmt.Errorf("error marshalling RacunZahtjev: %w", err) + } + + // Let's send it to CIS + body, status, errComm := fe.GetResponse(xmlData, true) + + if errComm != nil { + return "", invoice.ZastKod, fmt.Errorf("failed to make request: %w", errComm) + } + + //unmarshad body to get Racun Odgovor + var racunOdgovor RacunOdgovor + if err := xml.Unmarshal(body, &racunOdgovor); err != nil { + return "", invoice.ZastKod, fmt.Errorf("failed to unmarshal XML response: %w", err) + } + + if zahtjev.Zaglavlje.IdPoruke != racunOdgovor.Zaglavlje.IdPoruke { + return "", invoice.ZastKod, errors.New("IdPoruke mismatch") + } + + if status != 200 { + + // Aggregate all errors into a single error message + var errorMessages []string + for _, greska := range racunOdgovor.Greske.Greska { + errorMessages = append(errorMessages, fmt.Sprintf("%s: %s", greska.SifraGreske, greska.PorukaGreske)) + } + if len(errorMessages) > 0 { + return "", invoice.ZastKod, fmt.Errorf("errors in response: %s", strings.Join(errorMessages, "; ")) + } + + } else { + if racunOdgovor.Jir != "" { + return racunOdgovor.Jir, invoice.ZastKod, nil + } else { + return "", invoice.ZastKod, errors.New("JIR is empty") + } + } + + // Add a default return statement to handle unexpected cases + return "", invoice.ZastKod, errors.New("unexpected error") +} diff --git a/fiskalhr_test.go b/fiskalhr_test.go index abd5ab7..a11c2fd 100644 --- a/fiskalhr_test.go +++ b/fiskalhr_test.go @@ -196,7 +196,7 @@ func TestNewCISInvoice(t *testing.T) { oibOper := "12345678901" nakDost := true paragonBrRac := "12345" - specNamj := "Special Purpose" + specNamj := "" invoice, zki, err := testEntity.NewCISInvoice( dateTime, @@ -294,51 +294,25 @@ func TestNewCISInvoice(t *testing.T) { t.Errorf("Expected Naknade to be non-nil") } - //Combine with zahtjev for final XML - zahtjev := RacunZahtjev{ - Zaglavlje: NewFiskalHeader(), - Racun: invoice, - Xmlns: DefaultNamespace, - IdAttr: generateUniqueID(), - } - - t.Logf("Zahtijev UUID: %s", zahtjev.Zaglavlje.IdPoruke) - t.Logf("Zahtijev Timestamp: %s", zahtjev.Zaglavlje.DatumVrijeme) - - // Marshal the RacunZahtjev to XML - xmlData, err := xml.MarshalIndent(zahtjev, "", " ") + //marshal invoice and log XML + xmlData, err := xml.MarshalIndent(invoice, "", " ") if err != nil { - t.Fatalf("Error marshalling RacunZahtjev: %v", err) + t.Fatalf("Expected no error, got %v", err) } - t.Log(string(xmlData)) + t.Logf("Invoice XML: %s", xmlData) - // Lets send it to CIS and see if we get a response - body, status, errComm := testEntity.GetResponse(xmlData, true) + // Send test invoice to CIS with InvoiceRequest + jir, zkiR, err := testEntity.InvoiceRequest(invoice) - if errComm != nil { - t.Fatalf("Failed to make request: %v", errComm) + if err != nil { + t.Fatalf("Expected no error, got %v", err) } - //unmarshad bodyu to get Racun Odgovor - var racunOdgovor RacunOdgovor - if err := xml.Unmarshal(body, &racunOdgovor); err != nil { - t.Fatalf("failed to unmarshal XML response: %v\n%v", err, string(body)) + if zkiR != zki { + t.Errorf("Expected ZKI %v, got %v", zki, zkiR) } - //output zaglavlje first all elements - t.Logf("Racun Odgovor IdPoruke: %s", racunOdgovor.Zaglavlje.IdPoruke) - t.Logf("Racun Odgovor DatumVrijeme: %s", racunOdgovor.Zaglavlje.DatumVrijeme) - - if status != 200 { - - //all errors one by one - for _, greska := range racunOdgovor.Greske.Greska { - t.Logf("Racun Odgovor Greska: %s: %s", greska.SifraGreske, greska.PorukaGreske) - } + t.Logf("We got a JIR!: %v, ZKI: %v", jir, zkiR) - } else { - //output JIR: Jedinicni identifikator racuna - t.Logf("Racun Odgovor JIR: %s", racunOdgovor.Jir) - } }