Skip to content

Commit

Permalink
Refactor ciscomm.go and dsignandverify.go for code cleanup and optimi…
Browse files Browse the repository at this point in the history
…zation

Add InvoiceRequest method
  • Loading branch information
arvvoid committed Sep 24, 2024
1 parent 3ee4f8d commit 796692f
Show file tree
Hide file tree
Showing 6 changed files with 270 additions and 40 deletions.
151 changes: 151 additions & 0 deletions canonicalization_test.go
Original file line number Diff line number Diff line change
@@ -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 = `<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_88a93ebe-abdf-48cd-9ed0-b0dd1b252909" Version="2.0" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" AssertionConsumerServiceURL="https://saml2.test.astuart.co/sso/saml2" AssertionConsumerServiceIndex="0" AttributeConsumingServiceIndex="0" IssueInstant="2016-04-28T15:37:17" Destination="http://idp.astuart.co/idp/profile/SAML2/Redirect/SSO"><!-- Some Comment --><saml:Issuer>https://saml2.test.astuart.co/sso/saml2</saml:Issuer><samlp:NameIDPolicy AllowCreate="true" Format=""/><samlp:RequestedAuthnContext Comparison="exact"><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext></samlp:AuthnRequest>`
c14n11 = `<samlp:AuthnRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" AssertionConsumerServiceIndex="0" AssertionConsumerServiceURL="https://saml2.test.astuart.co/sso/saml2" AttributeConsumingServiceIndex="0" Destination="http://idp.astuart.co/idp/profile/SAML2/Redirect/SSO" ID="_88a93ebe-abdf-48cd-9ed0-b0dd1b252909" IssueInstant="2016-04-28T15:37:17" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Version="2.0"><saml:Issuer>https://saml2.test.astuart.co/sso/saml2</saml:Issuer><samlp:NameIDPolicy AllowCreate="true" Format=""></samlp:NameIDPolicy><samlp:RequestedAuthnContext Comparison="exact"><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext></samlp:AuthnRequest>`
assertionC14ned = `<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" AssertionConsumerServiceIndex="0" AssertionConsumerServiceURL="https://saml2.test.astuart.co/sso/saml2" AttributeConsumingServiceIndex="0" Destination="http://idp.astuart.co/idp/profile/SAML2/Redirect/SSO" ID="_88a93ebe-abdf-48cd-9ed0-b0dd1b252909" IssueInstant="2016-04-28T15:37:17" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Version="2.0"><saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://saml2.test.astuart.co/sso/saml2</saml:Issuer><samlp:NameIDPolicy AllowCreate="true" Format=""></samlp:NameIDPolicy><samlp:RequestedAuthnContext Comparison="exact"><saml:AuthnContextClassRef xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext></samlp:AuthnRequest>`
c14n11Comment = `<samlp:AuthnRequest xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" AssertionConsumerServiceIndex="0" AssertionConsumerServiceURL="https://saml2.test.astuart.co/sso/saml2" AttributeConsumingServiceIndex="0" Destination="http://idp.astuart.co/idp/profile/SAML2/Redirect/SSO" ID="_88a93ebe-abdf-48cd-9ed0-b0dd1b252909" IssueInstant="2016-04-28T15:37:17" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Version="2.0"><!-- Some Comment --><saml:Issuer>https://saml2.test.astuart.co/sso/saml2</saml:Issuer><samlp:NameIDPolicy AllowCreate="true" Format=""></samlp:NameIDPolicy><samlp:RequestedAuthnContext Comparison="exact"><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext></samlp:AuthnRequest>`
assertionC14nedComment = `<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" AssertionConsumerServiceIndex="0" AssertionConsumerServiceURL="https://saml2.test.astuart.co/sso/saml2" AttributeConsumingServiceIndex="0" Destination="http://idp.astuart.co/idp/profile/SAML2/Redirect/SSO" ID="_88a93ebe-abdf-48cd-9ed0-b0dd1b252909" IssueInstant="2016-04-28T15:37:17" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Version="2.0"><!-- Some Comment --><saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://saml2.test.astuart.co/sso/saml2</saml:Issuer><samlp:NameIDPolicy AllowCreate="true" Format=""></samlp:NameIDPolicy><samlp:RequestedAuthnContext Comparison="exact"><saml:AuthnContextClassRef xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></samlp:RequestedAuthnContext></samlp:AuthnRequest>`
)

const (
xmldoc = `<Foo ID="id1619705532971228558789260" xmlns:bar="urn:bar" xmlns="urn:foo"><bar:Baz></bar:Baz></Foo>`
xmldocC14N10ExclusiveCanonicalized = `<Foo xmlns="urn:foo" ID="id1619705532971228558789260"><bar:Baz xmlns:bar="urn:bar"></bar:Baz></Foo>`
xmldocC14N11Canonicalized = `<Foo xmlns="urn:foo" xmlns:bar="urn:bar" ID="id1619705532971228558789260"><bar:Baz></bar:Baz></Foo>`
)

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 := `<X xmlns:x="x" xmlns:y="y"><Y xmlns:x="x" xmlns:y="y" xmlns:z="z"/></X>`
expected := `<X xmlns:x="x" xmlns:y="y"><Y xmlns:z="z"></Y></X>`
runCanonicalizationTest(t, MakeC14N11Canonicalizer(), input, expected)
}

func TestExcC14nDefaultNamespace(t *testing.T) {
input := `<foo:Foo xmlns="urn:baz" xmlns:foo="urn:foo"><foo:Bar></foo:Bar></foo:Foo>`
expected := `<foo:Foo xmlns:foo="urn:foo"><foo:Bar></foo:Bar></foo:Foo>`
runCanonicalizationTest(t, MakeC14N10ExclusiveCanonicalizerWithPrefixList(""), input, expected)
}

func TestExcC14nWithPrefixList(t *testing.T) {
input := `<foo:Foo xmlns:foo="urn:foo" xmlns:xs="http://www.w3.org/2001/XMLSchema"><foo:Bar xmlns:xs="http://www.w3.org/2001/XMLSchema"></foo:Bar></foo:Foo>`
expected := `<foo:Foo xmlns:foo="urn:foo" xmlns:xs="http://www.w3.org/2001/XMLSchema"><foo:Bar></foo:Bar></foo:Foo>`
canonicalizer := MakeC14N10ExclusiveCanonicalizerWithPrefixList("xs")
runCanonicalizationTest(t, canonicalizer, input, expected)
}

func TestExcC14nRedeclareDefaultNamespace(t *testing.T) {
input := `<Foo xmlns="urn:foo"><Bar xmlns="uri:bar"></Bar></Foo>`
expected := `<Foo xmlns="urn:foo"><Bar xmlns="uri:bar"></Bar></Foo>`
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 := `<doc>
<e1 />
<e2 ></e2>
<e3 name = "elem3" id="elem3" />
<e4 name="elem4" id="elem4" ></e4>
<e5 a:attr="out" b:attr="sorted" attr2="all" attr="I'm"
xmlns:b="http://www.ietf.org"
xmlns:a="http://www.w3.org"
xmlns="http://example.org"/>
<e6 xmlns="" xmlns:a="http://www.w3.org">
<e7 xmlns="http://www.ietf.org">
<e8 xmlns="" xmlns:a="http://www.w3.org">
<e9 xmlns="" xmlns:a="http://www.ietf.org"/>
</e8>
</e7>
</e6>
</doc>`
expected := `<doc>
<e1></e1>
<e2></e2>
<e3 id="elem3" name="elem3"></e3>
<e4 id="elem4" name="elem4"></e4>
<e5 xmlns="http://example.org" xmlns:a="http://www.w3.org" xmlns:b="http://www.ietf.org" attr="I'm" attr2="all" b:attr="sorted" a:attr="out"></e5>
<e6 xmlns:a="http://www.w3.org">
<e7 xmlns="http://www.ietf.org">
<e8 xmlns="">
<e9 xmlns:a="http://www.ietf.org"></e9>
</e8>
</e7>
</e6>
</doc>`

canonicalizer := MakeC14N10RecCanonicalizer()
runCanonicalizationTest(t, canonicalizer, input, expected)
}

func TestC14N10RecCanonicalizerWithNamespaceInheritance(t *testing.T) {
input := `<RootElement xmlns="http://www.example.com/ns1" xmlns:ns2="http://www.example.com/ns2">
<ns2:ChildElement>
<ns2:GrandChildElement>Hello, World!</ns2:GrandChildElement>
</ns2:ChildElement>
</RootElement>`

expected := `<ns2:ChildElement xmlns="http://www.example.com/ns1" xmlns:ns2="http://www.example.com/ns2">
<ns2:GrandChildElement>Hello, World!</ns2:GrandChildElement>
</ns2:ChildElement>`

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)))

}
1 change: 0 additions & 1 deletion ciscomm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion dsignandverify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions fiskal-schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
105 changes: 105 additions & 0 deletions fiskalhr.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)

Expand Down Expand Up @@ -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")
}
50 changes: 12 additions & 38 deletions fiskalhr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
}

0 comments on commit 796692f

Please sign in to comment.