From 61907b956b6040ddabb7f2db5f0d282bc76ebd1e Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Fri, 2 Aug 2024 16:04:14 +0000 Subject: [PATCH 01/27] Basic header with just supplier --- address.go | 77 +++++++++++++++++++++++++++ detail.go | 20 +++++++ go.mod | 23 +++++++++ go.sum | 55 ++++++++++++++++++++ head.go | 22 ++++++++ invoice-valid.json | 126 +++++++++++++++++++++++++++++++++++++++++++++ invoice.go | 32 ++++++++++++ nav.go | 90 ++++++++++++++++++++++++++++++++ summary.go | 28 ++++++++++ supplier.go | 28 ++++++++++ taxnumber.go | 18 +++++++ 11 files changed, 519 insertions(+) create mode 100644 address.go create mode 100644 detail.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 head.go create mode 100644 invoice-valid.json create mode 100644 invoice.go create mode 100644 nav.go create mode 100644 summary.go create mode 100644 supplier.go create mode 100644 taxnumber.go diff --git a/address.go b/address.go new file mode 100644 index 0000000..4d7a1b7 --- /dev/null +++ b/address.go @@ -0,0 +1,77 @@ +package nav + +import ( + "fmt" + + "github.com/invopop/gobl/org" +) + +type Address struct { + SimpleAddress SimpleAddress `xml:"base:simpleAddress,omitempty"` + //DetailedAddress DetailedAddress `xml:"base:detailedAddress,omitempty"` +} + +// DetailedAddressType represents detailed address data +/*type DetailedAddress struct { + CountryCode string `xml:"base:countryCode"` + Region string `xml:"base:region,omitempty"` + PostalCode string `xml:"base:postalCode"` + City string `xml:"base:city"` + StreetName string `xml:"base:streetName"` + PublicPlaceCategory string `xml:"base:publicPlaceCategory"` + Number string `xml:"base:number,omitempty"` + Building string `xml:"base:building,omitempty"` + Staircase string `xml:"base:staircase,omitempty"` + Floor string `xml:"base:floor,omitempty"` + Door string `xml:"base:door,omitempty"` + LotNumber string `xml:"base:lotNumber,omitempty"` +}*/ + +// GOBL does not support dividing the address into public place category and street name +// For the moment we can use SimpleAddress + +// SimpleAddressType represents a simple address +type SimpleAddress struct { + CountryCode string `xml:"countryCode"` + //Region string `xml:"region,omitempty"` + PostalCode string `xml:"base:postalCode"` + City string `xml:"base:city"` + AdditionalAddressDetail string `xml:"base:additionalAddressDetail"` +} + +func NewAddress(address *org.Address) *Address { + return &Address{ + SimpleAddress: SimpleAddress{ + CountryCode: address.Country.String(), + PostalCode: address.Code, + City: address.Locality, + AdditionalAddressDetail: formatAddress(address), + }, + } +} + +func formatAddress(address *org.Address) string { + if address.PostOfficeBox != "" { + return "PO Box / Apdo " + address.PostOfficeBox + } + + formattedAddress := fmt.Sprintf("%s, %s", address.Street, address.Number) + + if address.Block != "" { + formattedAddress += (", " + address.Block) + } + + if address.Floor != "" { + formattedAddress += (", " + address.Floor) + } + + if address.Door != "" { + formattedAddress += (" " + address.Door) + } + + if address.StreetExtra != "" { + formattedAddress += ("\n" + address.StreetExtra) + } + + return formattedAddress +} diff --git a/detail.go b/detail.go new file mode 100644 index 0000000..b5d3992 --- /dev/null +++ b/detail.go @@ -0,0 +1,20 @@ +package nav + +type InvoiceDetail struct { + InvoiceCategory string `xml:"invoiceCategory"` + InvoiceDeliveryDate string `xml:"invoiceDeliveryDate"` + InvoiceDeliveryPeriodStart string `xml:"invoiceDeliveryPeriodStart,omitempty"` + InvoiceDeliveryPeriodEnd string `xml:"invoiceDeliveryPeriodEnd,omitempty"` + InvoiceAccountingDeliveryDate string `xml:"invoiceAccountingDeliveryDate,omitempty"` + PeriodicalSettlement bool `xml:"periodicalSettlement,omitempty"` + SmallBusinessIndicator bool `xml:"smallBusinessIndicator,omitempty"` + CurrencyCode string `xml:"currencyCode"` + ExchangeRate string `xml:"exchangeRate,omitempty"` + UtilitySettlementIndicator bool `xml:"utilitySettlementIndicator,omitempty"` + SelfBillingIndicator bool `xml:"selfBillingIndicator,omitempty"` + PaymentMethod string `xml:"paymentMethod,omitempty"` + PaymentDate string `xml:"paymentDate,omitempty"` + CashAccountingIndicator bool `xml:"cashAccountingIndicator,omitempty"` + InvoiceAppearance string `xml:"invoiceAppearance"` + //Some more optional data +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fbfa51f --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/invopop/gobl.hu-nav + +go 1.22.3 + +require github.com/invopop/gobl v0.113.0 + +require ( + cloud.google.com/go v0.110.2 // indirect + github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.12.0 // indirect + github.com/invopop/validation v0.7.0 // indirect + github.com/invopop/yaml v0.3.1 // indirect + github.com/magefile/mage v1.15.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + golang.org/x/crypto v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b138f9f --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= +cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/gobl v0.113.0 h1:Ap3Kq5bEkNWlVt8zsn2dkA4dhU5skCoMkOTfTHA33DA= +github.com/invopop/gobl v0.113.0/go.mod h1:lnlUK1cwjla/EPdxH1O7hKSSBv58DNWLV4/lOCv1vlQ= +github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= +github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/invopop/validation v0.7.0 h1:NBPLqvYGmLZLQuk5jh0PbaBBetJW7f2VEk/BTWJkGBU= +github.com/invopop/validation v0.7.0/go.mod h1:nLLeXYPGwUNfdCdJo7/q3yaHO62LSx/3ri7JvgKR9vg= +github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= +github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 h1:wD1IWQwAhdWclCwaf6DdzgCAe9Bfz1M+4AHRd7N786Y= +github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693/go.mod h1:6hSY48PjDm4UObWmGLyJE9DxYVKTgR9kbCspXXJEhcU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/head.go b/head.go new file mode 100644 index 0000000..3d91ef0 --- /dev/null +++ b/head.go @@ -0,0 +1,22 @@ +package nav + +import "github.com/invopop/gobl/bill" + +type InvoiceHead struct { + SupplierInfo *SupplierInfo `xml:"supplierInfo"` + //CustomerInfo CustomerInfo `xml:"customerInfo,omitempty"` + //FiscalRepresentativeInfo FiscalRepresentativeInfo `xml:"fiscalRepresentativeInfo,omitempty"` + + //InvoiceDetail InvoiceDetail `xml:"invoiceDetail"` +} + +func NewInvoiceHead(inv *bill.Invoice) (*InvoiceHead, error) { + supplierInfo, err := NewSupplierInfo(inv.Supplier) + if err != nil { + return nil, err + } + return &InvoiceHead{ + SupplierInfo: supplierInfo, + //InvoiceDetail: NewInvoiceDetail(inv), + }, nil +} diff --git a/invoice-valid.json b/invoice-valid.json new file mode 100644 index 0000000..dbbfbbd --- /dev/null +++ b/invoice-valid.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "8a51fd30-2a27-11ee-be56-0242ac120002", + "dig": { + "alg": "sha256", + "val": "fb3e81ee5d0964fa423dcfb62309a7a5c5150dc62cdd81427a68a2c85e893a66" + }, + "draft": true + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "type": "standard", + "series": "SAMPLE", + "code": "001", + "issue_date": "2024-02-13", + "currency": "EUR", + "supplier": { + "name": "Provide One GmbH", + "tax_id": { + "country": "HU", + "code": "111111125" + }, + "people": [ + { + "name": { + "given": "John", + "surname": "Doe" + } + } + ], + "addresses": [ + { + "num": "16", + "street": "Dietmar-Hopp-Allee", + "locality": "Walldorf", + "code": "69190", + "country": "DE" + } + ], + "emails": [ + { + "addr": "billing@example.com" + } + ], + "telephones": [ + { + "num": "+49100200300" + } + ] + }, + "customer": { + "name": "Sample Consumer", + "tax_id": { + "country": "DE", + "code": "282741168" + }, + "addresses": [ + { + "num": "25", + "street": "Werner-Heisenberg-Allee", + "locality": "München", + "code": "80939", + "country": "DE" + } + ], + "emails": [ + { + "addr": "email@sample.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Development services", + "price": "90.00", + "unit": "h" + }, + "sum": "1800.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "19%" + } + ], + "total": "1800.00" + } + ], + "ordering": { + "code": "XR-2024-2" + }, + "payment": { + "terms": { + "detail": "lorem ipsum" + } + }, + "totals": { + "sum": "1800.00", + "total": "1800.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "1800.00", + "percent": "19%", + "amount": "342.00" + } + ], + "amount": "342.00" + } + ], + "sum": "342.00" + }, + "tax": "342.00", + "total_with_tax": "2142.00", + "payable": "2142.00" + } + } +} \ No newline at end of file diff --git a/invoice.go b/invoice.go new file mode 100644 index 0000000..045ccc7 --- /dev/null +++ b/invoice.go @@ -0,0 +1,32 @@ +package nav + +import "github.com/invopop/gobl/bill" + +// InvoiceMain can have 2 values: Invoice and batchInvoice +// For the moment, we are only going to focus on invoice +type InvoiceMain struct { + Invoice *Invoice `xml:"invoice"` +} + +type Invoice struct { + //InvoiceReference InvoiceReference `xml:"invoiceReference,omitempty"` + InvoiceHead *InvoiceHead `xml:"invoiceHead"` + //InvoiceLines InvoiceLines `xml:"invoiceLines,omitempty"` + //ProductFeeSummary ProductFeeSummary `xml:"productFeeSummary,omitempty"` + + //InvoiceSummary *InvoiceSummary `xml:"invoiceSummary"` +} + +func NewInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { + invoiceHead, err := NewInvoiceHead(inv) + if err != nil { + return nil, err + } + + return &InvoiceMain{ + Invoice: &Invoice{ + InvoiceHead: invoiceHead, + //InvoiceSummary: NewInvoiceSummary(inv), + }, + }, nil +} diff --git a/nav.go b/nav.go new file mode 100644 index 0000000..71387c0 --- /dev/null +++ b/nav.go @@ -0,0 +1,90 @@ +package nav + +import ( + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "os" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/bill" +) + +/* +*/ + +// Standard error responses. +var ( + ErrNotHungarian = newValidationError("only hungarian invoices are supported") +) + +// ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed +// to server-side errors (that should be retried). +type ValidationError struct { + err error +} + +// Error implements the error interface for ClientError. +func (e *ValidationError) Error() string { + return e.err.Error() +} + +func newValidationError(text string) error { + return &ValidationError{errors.New(text)} +} + +type Document struct { + XMLName xml.Name `xml:"InvoiceData"` + XMLNS string `xml:"xmlns,attr"` + //XMLNSXsi string `xml:"xmlns:xsi,attr"` + //XSISchema string `xml:"xsi:schemaLocation,attr"` + //XMLNSCommon string `xml:"xmlns:common,attr"` + //XMLNSBase string `xml:"xmlns:base,attr"` + InvoiceNumber string `xml:"invoiceNumber"` + InvoiceIssueDate string `xml:"invoiceIssueDate"` + CompletenessIndicator bool `xml:"completenessIndicator"` // Indicates whether the data report is the invoice itself + InvoiceMain *InvoiceMain `xml:"invoiceMain"` +} + +// Convert it to XML before returning +func NewDocument(inv *bill.Invoice) *Document { + d := new(Document) + d.XMLNS = "http://schemas.nav.gov.hu/OSA/3.0/data" + //d.XMLNSXsi = "http://www.w3.org/2001/XMLSchema-instance" + //d.XSISchema = "http://schemas.nav.gov.hu/OSA/3.0/data invoiceData.xsd" + //d.XMLNSCommon = "http://schemas.nav.gov.hu/NTCA/1.0/common" + //d.XMLNSBase = "http://schemas.nav.gov.hu/OSA/3.0/base" + d.InvoiceNumber = inv.Code + d.InvoiceIssueDate = inv.IssueDate.String() + d.CompletenessIndicator = false + main, err := NewInvoiceMain(inv) + if err != nil { + panic(err) + } + d.InvoiceMain = main + return d +} + +func main() { + data, _ := os.ReadFile("invoice-valid.json") + fmt.Println(string(data)) + env := new(gobl.Envelope) + if err := json.Unmarshal(data, env); err != nil { + panic(err) + } + + inv, ok := env.Extract().(*bill.Invoice) + if !ok { + fmt.Errorf("invalid type %T", env.Document) + } + + doc := NewDocument(inv) + // Print the XML + output, err := xml.MarshalIndent(doc, "", " ") + if err != nil { + panic(err) + } + fmt.Println(string(output)) +} diff --git a/summary.go b/summary.go new file mode 100644 index 0000000..4b80526 --- /dev/null +++ b/summary.go @@ -0,0 +1,28 @@ +package nav + +// Depends wether the invoice is simplified or not +/*type InvoiceSummary struct { + SummaryNormal SummaryNormal `xml:"summaryNormal,omitempty"` + SummarySimplified SummarySimplified `xml:"summarySimplified,omitempty"` + //SummaryGrossData SummaryGrossData `xml:"summaryGrossData,omitempty"` +} + +type SummaryNormal struct { + SummaryByVatRate SummaryByVatRate `xml:"summaryByVatRate"` //probably this is a list + InvoiceNetAmount float64 `xml:"invoiceNetAmount"` + InvoiceNetAmountHUF float64 `xml:"invoiceNetAmountHUF"` + InvoiceVatAmount float64 `xml:"invoiceVatAmount"` + InvoiceVatAmountHUF float64 `xml:"invoiceVatAmountHUF"` +} + +type SummaryByVatRate struct { + VatRate VatRate `xml:"vatRate"` + VatRateNetData VatRateNetData `xml:"vatRateNetData"` + VatRateVatData VatRateVatData `xml:"vatRateVatData"` + VatRateGrossData VatRateGrossData `xml:"vatRateGrossData, omitempty"` +} + +type VatRate struct { + VatPercentage float64 `xml:"vatPercentage, omitempty"` + VatContent float64 `xml:"vatContent, omitempty"` +}*/ diff --git a/supplier.go b/supplier.go new file mode 100644 index 0000000..1ea434f --- /dev/null +++ b/supplier.go @@ -0,0 +1,28 @@ +package nav + +import ( + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/org" +) + +type SupplierInfo struct { + SupplierTaxNumber *TaxNumber `xml:"supplierTaxNumber"` + //GroupMemberTaxNumber TaxNumber `xml:"groupMemberTaxNumber,omitempty"` + //CommunityVATNumber string `xml:"communityVATNumber,omitempty"` + SupplierName string `xml:"supplierName"` + SupplierAddress *Address `xml:"supplierAddress"` + //SupplierBankAccount string `xml:"supplierBankAccount,omitempty"` + //IndividualExemption bool `xml:"individualExemption,omitempty"` + //ExciseLicenceNum string `xml:"exciseLicenceNum,omitempty"` +} + +func NewSupplierInfo(supplier *org.Party) (*SupplierInfo, error) { + if supplier.TaxID.Country != l10n.HU.Tax() { + return nil, ErrNotHungarian + } + return &SupplierInfo{ + SupplierTaxNumber: NewTaxNumber(supplier.TaxID), + SupplierName: supplier.Name, + SupplierAddress: NewAddress(supplier.Addresses[0]), + }, nil +} diff --git a/taxnumber.go b/taxnumber.go new file mode 100644 index 0000000..ed626be --- /dev/null +++ b/taxnumber.go @@ -0,0 +1,18 @@ +package nav + +import "github.com/invopop/gobl/tax" + +type TaxNumber struct { + TaxPayerID string `xml:"base:taxpayerId"` + //VatCode string `xml:"base:vatCode,omitempty"` + //CountyCode string `xml:"base:countyCode,omitempty"` +} + +// Have to look at the vatcodes for the regime + +// NewTaxNumber creates a new TaxNumber from a taxid +func NewTaxNumber(taxid *tax.Identity) *TaxNumber { + return &TaxNumber{ + TaxPayerID: taxid.Code.String(), + } +} From ffa8f9cc59cdb950ca1f87e949b7a6b8d80e7475 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Mon, 5 Aug 2024 15:51:59 +0000 Subject: [PATCH 02/27] Summary done --- detail.go | 94 +++++++++++++++++++++++++++++++++++++++++++++--------- head.go | 11 +++++-- invoice.go | 11 +++++-- nav.go | 11 +++---- summary.go | 83 +++++++++++++++++++++++++++++++++++++---------- vatrate.go | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+), 44 deletions(-) create mode 100644 vatrate.go diff --git a/detail.go b/detail.go index b5d3992..ded18e4 100644 --- a/detail.go +++ b/detail.go @@ -1,20 +1,84 @@ package nav +import ( + "math" + "strconv" + "strings" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/currency" +) + type InvoiceDetail struct { - InvoiceCategory string `xml:"invoiceCategory"` - InvoiceDeliveryDate string `xml:"invoiceDeliveryDate"` - InvoiceDeliveryPeriodStart string `xml:"invoiceDeliveryPeriodStart,omitempty"` - InvoiceDeliveryPeriodEnd string `xml:"invoiceDeliveryPeriodEnd,omitempty"` - InvoiceAccountingDeliveryDate string `xml:"invoiceAccountingDeliveryDate,omitempty"` - PeriodicalSettlement bool `xml:"periodicalSettlement,omitempty"` - SmallBusinessIndicator bool `xml:"smallBusinessIndicator,omitempty"` - CurrencyCode string `xml:"currencyCode"` - ExchangeRate string `xml:"exchangeRate,omitempty"` - UtilitySettlementIndicator bool `xml:"utilitySettlementIndicator,omitempty"` - SelfBillingIndicator bool `xml:"selfBillingIndicator,omitempty"` - PaymentMethod string `xml:"paymentMethod,omitempty"` - PaymentDate string `xml:"paymentDate,omitempty"` - CashAccountingIndicator bool `xml:"cashAccountingIndicator,omitempty"` - InvoiceAppearance string `xml:"invoiceAppearance"` + InvoiceCategory string `xml:"invoiceCategory"` //NORMAL, SIMPLIFIED, AGGREGATE + InvoiceDeliveryDate string `xml:"invoiceDeliveryDate"` + //InvoiceDeliveryPeriodStart string `xml:"invoiceDeliveryPeriodStart,omitempty"` + //InvoiceDeliveryPeriodEnd string `xml:"invoiceDeliveryPeriodEnd,omitempty"` + //InvoiceAccountingDeliveryDate string `xml:"invoiceAccountingDeliveryDate,omitempty"` + //PeriodicalSettlement bool `xml:"periodicalSettlement,omitempty"` + //SmallBusinessIndicator bool `xml:"smallBusinessIndicator,omitempty"` + CurrencyCode string `xml:"currencyCode"` + ExchangeRate float64 `xml:"exchangeRate"` + //UtilitySettlementIndicator bool `xml:"utilitySettlementIndicator,omitempty"` + //SelfBillingIndicator bool `xml:"selfBillingIndicator,omitempty"` + //PaymentMethod string `xml:"paymentMethod,omitempty"` + //PaymentDate string `xml:"paymentDate,omitempty"` + //CashAccountingIndicator bool `xml:"cashAccountingIndicator,omitempty"` + InvoiceAppearance string `xml:"invoiceAppearance"` // PAPER, ELECTRONIC, EDI, UNKNOWN //Some more optional data } + +// NewInvoiceDetail creates a new InvoiceDetail from an invoice +func NewInvoiceDetail(inv *bill.Invoice) (*InvoiceDetail, error) { + rate, err := getInvoiceRate(inv) + if err != nil { + return nil, err + } + + return &InvoiceDetail{ + InvoiceCategory: "NORMAL", + InvoiceDeliveryDate: inv.OperationDate.String(), + CurrencyCode: inv.Currency.String(), + ExchangeRate: formatRate(rate), + InvoiceAppearance: "EDI", + }, nil +} + +func getInvoiceRate(inv *bill.Invoice) (float64, error) { + if inv.Currency == currency.HUF { + return 1.0, nil + } + + for _, ex := range inv.ExchangeRates { + if ex.To == currency.HUF { + return ex.Amount.Float64(), nil + } + } + + return -1.0, ErrNoExchangeRate +} + +func formatRate(value float64) float64 { + // Check if the float64 number has more than 6 decimal digits + if hasMoreThanSixDecimalDigits(value) { + return math.Round(value*1000000) / 1000000 + } + + // Convert the float64 number to a string without trailing zeros + return value +} + +// hasMoreThanSixDecimalDigits checks if a float64 number has more than 6 decimal digits +func hasMoreThanSixDecimalDigits(value float64) bool { + // Separate the fractional part from the integer part + fractionalPart := value - math.Floor(value) + + // Convert the fractional part to a string + fractionalStr := strconv.FormatFloat(fractionalPart, 'f', -1, 64) + + // Remove the leading "0." from the string representation + fractionalStr = strings.TrimPrefix(fractionalStr, "0.") + + // Check the length of the fractional part + return len(fractionalStr) > 6 +} diff --git a/head.go b/head.go index 3d91ef0..85b10aa 100644 --- a/head.go +++ b/head.go @@ -7,7 +7,7 @@ type InvoiceHead struct { //CustomerInfo CustomerInfo `xml:"customerInfo,omitempty"` //FiscalRepresentativeInfo FiscalRepresentativeInfo `xml:"fiscalRepresentativeInfo,omitempty"` - //InvoiceDetail InvoiceDetail `xml:"invoiceDetail"` + InvoiceDetail *InvoiceDetail `xml:"invoiceDetail"` } func NewInvoiceHead(inv *bill.Invoice) (*InvoiceHead, error) { @@ -15,8 +15,13 @@ func NewInvoiceHead(inv *bill.Invoice) (*InvoiceHead, error) { if err != nil { return nil, err } + + detail, err := NewInvoiceDetail(inv) + if err != nil { + return nil, err + } return &InvoiceHead{ - SupplierInfo: supplierInfo, - //InvoiceDetail: NewInvoiceDetail(inv), + SupplierInfo: supplierInfo, + InvoiceDetail: detail, }, nil } diff --git a/invoice.go b/invoice.go index 045ccc7..1fa3ee4 100644 --- a/invoice.go +++ b/invoice.go @@ -14,7 +14,7 @@ type Invoice struct { //InvoiceLines InvoiceLines `xml:"invoiceLines,omitempty"` //ProductFeeSummary ProductFeeSummary `xml:"productFeeSummary,omitempty"` - //InvoiceSummary *InvoiceSummary `xml:"invoiceSummary"` + InvoiceSummary *InvoiceSummary `xml:"invoiceSummary"` } func NewInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { @@ -23,10 +23,15 @@ func NewInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { return nil, err } + invoiceSummary, err := NewInvoiceSummary(inv) + if err != nil { + return nil, err + } + return &InvoiceMain{ Invoice: &Invoice{ - InvoiceHead: invoiceHead, - //InvoiceSummary: NewInvoiceSummary(inv), + InvoiceHead: invoiceHead, + InvoiceSummary: invoiceSummary, }, }, nil } diff --git a/nav.go b/nav.go index 71387c0..2613089 100644 --- a/nav.go +++ b/nav.go @@ -1,13 +1,9 @@ package nav import ( - "encoding/json" "encoding/xml" "errors" - "fmt" - "os" - "github.com/invopop/gobl" "github.com/invopop/gobl/bill" ) @@ -17,7 +13,8 @@ xmlns:common="http://schemas.nav.gov.hu/NTCA/1.0/common" xmlns:base="http://sche // Standard error responses. var ( - ErrNotHungarian = newValidationError("only hungarian invoices are supported") + ErrNotHungarian = newValidationError("only hungarian invoices are supported") + ErrNoExchangeRate = newValidationError("no exchange rate to HUF found") ) // ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed @@ -67,7 +64,7 @@ func NewDocument(inv *bill.Invoice) *Document { return d } -func main() { +/*func main() { data, _ := os.ReadFile("invoice-valid.json") fmt.Println(string(data)) env := new(gobl.Envelope) @@ -87,4 +84,4 @@ func main() { panic(err) } fmt.Println(string(output)) -} +}*/ diff --git a/summary.go b/summary.go index 4b80526..c4f0902 100644 --- a/summary.go +++ b/summary.go @@ -1,28 +1,79 @@ package nav +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/tax" +) + // Depends wether the invoice is simplified or not -/*type InvoiceSummary struct { - SummaryNormal SummaryNormal `xml:"summaryNormal,omitempty"` - SummarySimplified SummarySimplified `xml:"summarySimplified,omitempty"` +type InvoiceSummary struct { + SummaryNormal *SummaryNormal `xml:"summaryNormal"` + // This is to differentiate between normal or simplified invoice, for the moment we are only doing normal + //SummarySimplified SummarySimplified `xml:"summarySimplified,omitempty"` //SummaryGrossData SummaryGrossData `xml:"summaryGrossData,omitempty"` } type SummaryNormal struct { - SummaryByVatRate SummaryByVatRate `xml:"summaryByVatRate"` //probably this is a list - InvoiceNetAmount float64 `xml:"invoiceNetAmount"` - InvoiceNetAmountHUF float64 `xml:"invoiceNetAmountHUF"` - InvoiceVatAmount float64 `xml:"invoiceVatAmount"` - InvoiceVatAmountHUF float64 `xml:"invoiceVatAmountHUF"` + SummaryByVatRate []*SummaryByVatRate `xml:"summaryByVatRate"` + InvoiceNetAmount float64 `xml:"invoiceNetAmount"` + InvoiceNetAmountHUF float64 `xml:"invoiceNetAmountHUF"` + InvoiceVatAmount float64 `xml:"invoiceVatAmount"` + InvoiceVatAmountHUF float64 `xml:"invoiceVatAmountHUF"` } type SummaryByVatRate struct { - VatRate VatRate `xml:"vatRate"` - VatRateNetData VatRateNetData `xml:"vatRateNetData"` - VatRateVatData VatRateVatData `xml:"vatRateVatData"` - VatRateGrossData VatRateGrossData `xml:"vatRateGrossData, omitempty"` + VatRate *VatRate `xml:"vatRate"` + VatRateNetData *VatRateNetData `xml:"vatRateNetData"` + VatRateVatData *VatRateVatData `xml:"vatRateVatData"` + //VatRateGrossData VatRateGrossData `xml:"vatRateGrossData, omitempty"` +} + +type VatRateNetData struct { + VatRateNetAmount float64 `xml:"vatRateNetAmount"` + VatRateNetAmountHUF float64 `xml:"vatRateNetAmountHUF"` +} + +type VatRateVatData struct { + VatRateVatAmount float64 `xml:"vatRateVatAmount"` + VatRateVatAmountHUF float64 `xml:"vatRateVatAmountHUF"` } -type VatRate struct { - VatPercentage float64 `xml:"vatPercentage, omitempty"` - VatContent float64 `xml:"vatContent, omitempty"` -}*/ +func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex float64) *SummaryByVatRate { + return &SummaryByVatRate{ + VatRate: NewVatRate(rate, info), + VatRateNetData: &VatRateNetData{ + VatRateNetAmount: rate.Base.Float64(), + VatRateNetAmountHUF: rate.Base.Float64() * ex, + }, + VatRateVatData: &VatRateVatData{ + VatRateVatAmount: rate.Amount.Float64(), + VatRateVatAmountHUF: rate.Amount.Float64() * ex, + }, + } +} + +func NewInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { + vat := inv.Totals.Taxes.Category(tax.CategoryVAT) + totalVat := 0.0 + summaryVat := []*SummaryByVatRate{} + taxInfo := newTaxInfo(inv) + ex, err := getInvoiceRate(inv) + if err != nil { + return nil, err + } + for _, rate := range vat.Rates { + summaryVat = append(summaryVat, newSummaryByVatRate(rate, taxInfo, ex)) + totalVat += rate.Amount.Float64() + } + + return &InvoiceSummary{ + SummaryNormal: &SummaryNormal{ + SummaryByVatRate: summaryVat, + InvoiceNetAmount: inv.Totals.Total.Float64(), + InvoiceNetAmountHUF: inv.Totals.Total.Float64() * ex, + InvoiceVatAmount: totalVat, + InvoiceVatAmountHUF: totalVat * ex, + }, + }, nil + +} diff --git a/vatrate.go b/vatrate.go new file mode 100644 index 0000000..cc56c74 --- /dev/null +++ b/vatrate.go @@ -0,0 +1,93 @@ +package nav + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/regimes/hu" + "github.com/invopop/gobl/tax" +) + +// Vat Rate may contain exactly one of the 8 possible fields +type VatRate struct { + VatPercentage float64 `xml:"vatPercentage,omitempty"` + //VatContent float64 `xml:"vatContent"` //VatContent is only for simplified invoices + VatExemption *DetailedReason `xml:"vatExemption,omitempty"` + VatOutOfScope *DetailedReason `xml:"vatOutOfScope,omitempty"` + VatDomesticReverseCharge bool `xml:"vatDomesticReverseCharge,omitempty"` + MarginSchemeIndicator string `xml:"marginSchemeIndicator,omitempty"` + VatAmountMismatch *VatAmountMismatch `xml:"vatAmountMismatch,omitempty"` + NoVatCharge bool `xml:"noVatCharge,omitempty"` +} + +type DetailedReason struct { + Case string `xml:"case"` + Reason string `xml:"reason"` +} + +type VatAmountMismatch struct { + VatRate float64 `xml:"vatRate"` + Case string `xml:"case"` +} + +type taxInfo struct { + //simplifiedRegime bool + outOfScope bool + domesticReverseCharge bool + travelAgency bool + secondHand bool + art bool + antique bool +} + +// NewVatRate creates a new VatRate from a taxid +func NewVatRate(rate *tax.RateTotal, info *taxInfo) *VatRate { + if rate.Key == tax.RateExempt { + if info.outOfScope { + // Q: Is there a way in GOBL to access the extension names? + // This could maybe be done accessing the regime and there the extensions. We can use the name as the reason. + return &VatRate{VatOutOfScope: &DetailedReason{Case: rate.Ext[hu.ExtKeyExemptionCode].String(), Reason: "Out of Scope"}} + } + if info.domesticReverseCharge { + return &VatRate{VatDomesticReverseCharge: true} + } + if info.travelAgency { + return &VatRate{MarginSchemeIndicator: "TRAVEL_AGENCY"} + } + if info.secondHand { + return &VatRate{MarginSchemeIndicator: "SECOND_HAND"} + } + if info.art { + return &VatRate{MarginSchemeIndicator: "ARTWORK"} + } + if info.antique { + return &VatRate{MarginSchemeIndicator: "ANTIQUE"} + } + return &VatRate{VatExemption: &DetailedReason{Case: rate.Ext[hu.ExtKeyExemptionCode].String(), Reason: "Exempt"}} + } + return &VatRate{VatPercentage: rate.Percent.Amount().Float64()} + + //TODO: Missing the last 2 cases (VatAmountMismatch and NoVatCharge) +} + +// Until PR approved in regimes this wont work +func newTaxInfo(inv *bill.Invoice) *taxInfo { + info := &taxInfo{} + if inv.Tax != nil { + for _, scheme := range inv.Tax.Tags { + switch scheme { + case hu.TagOutOfScope: + info.outOfScope = true + case hu.TagDomesticReverseCharge: + info.domesticReverseCharge = true + case hu.TagTravelAgency: + info.travelAgency = true + case hu.TagSecondHand: + info.secondHand = true + case hu.TagArt: + info.art = true + case hu.TagAntique: + info.antique = true + } + } + } + return info +} From 2e05f2aa528a6d58c26f96897dc5bd5d6af265ad Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Tue, 6 Aug 2024 17:55:40 +0000 Subject: [PATCH 03/27] Customer fields added --- .gitignore | 2 + README.md | 4 ++ customer.go | 124 +++++++++++++++++++++++++++++++++++++++++++++++++++ head.go | 2 +- summary.go | 43 ++++++++++-------- supplier.go | 27 +++++++---- taxnumber.go | 24 ++++++++-- vatrate.go | 3 ++ 8 files changed, 198 insertions(+), 31 deletions(-) create mode 100644 customer.go diff --git a/.gitignore b/.gitignore index 6f6f5e6..570bb80 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ # Go workspace file go.work go.work.sum + +/prueba \ No newline at end of file diff --git a/README.md b/README.md index 438dc49..19860bd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # gobl.hu-nav Convert GOBL into Hungarian NAV XML documents + +## Things to include in validation: +- If the 9th digit of the tax id is 5, the group member tax id must exist and should be 4. If the Vat Status is Domestic (Hungarian) always a vat id +- The 9th digit of the Vat Ids must be 1,2,3 or 5 and of the member groups must be 4. \ No newline at end of file diff --git a/customer.go b/customer.go new file mode 100644 index 0000000..3136269 --- /dev/null +++ b/customer.go @@ -0,0 +1,124 @@ +package nav + +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" +) + +type CustomerInfo struct { + CustomerVatStatus string `xml:"customerVatStatus"` + CustomerVatData *VatData `xml:"customerVatData,omitempty"` + CustomerName string `xml:"customerName,omitempty"` + CustomerAddress *Address `xml:"customerAddress,omitempty"` + // CustomerBankAccount string `xml:"customerBankAccountNumber,omitempty"` +} + +type VatData struct { + CustomerTaxNumber *CustomerTaxNumber `xml:"customerTaxNumber,omitempty"` + CommunityVATNumber string `xml:"communityVATNumber,omitempty"` + ThirdStateTaxId string `xml:"thirdStateTaxId,omitempty"` +} + +type CustomerTaxNumber struct { + TaxPayerID string `xml:"taxpayerId"` + VatCode string `xml:"vatCode,omitempty"` + CountyCode string `xml:"countyCode,omitempty"` + GroupMemberTaxNumber *TaxNumber `xml:"groupMemberTaxNumber,omitempty"` +} + +func NewCustomerInfo(inv *bill.Invoice) *CustomerInfo { + + customer := inv.Customer + + if inv.Tax.ContainsTag(hu.TagPrivatePerson) { + return &CustomerInfo{ + CustomerVatStatus: "PRIVATE_PERSON", + CustomerName: customer.Name, + CustomerAddress: NewAddress(customer.Addresses[0]), + } + } + + // If the customer is not a taxable person + if customer.TaxID == nil { + return &CustomerInfo{ + CustomerVatStatus: "OTHER", + CustomerName: customer.Name, + CustomerAddress: NewAddress(customer.Addresses[0]), + } + } + + taxID := customer.TaxID + group := false + status := "OTHER" + + if taxID.Country == l10n.HU.Tax() { + status = "DOMESTIC" + // One case for group Id and other for simple (Group ID has the 9th character as 5) + if taxID.Code.String()[8:9] == "5" { + group = true + } + } + return &CustomerInfo{ + CustomerVatStatus: status, + CustomerVatData: newVatData(customer, group, status), + CustomerName: customer.Name, + CustomerAddress: NewAddress(customer.Addresses[0]), + } +} + +func newVatData(customer *org.Party, group bool, status string) *VatData { + if status == "OTHER" { + return newOtherVatData(customer.TaxID) + } + return newDomesticVatData(customer, group) +} + +func newOtherVatData(taxID *tax.Identity) *VatData { + if isEuropeanCountryCode(taxID.Country.Code()) { + return &VatData{ + CommunityVATNumber: taxID.String(), + } + } + return &VatData{ + ThirdStateTaxId: taxID.String(), + } +} + +func newDomesticVatData(customer *org.Party, group bool) *VatData { + taxID := customer.TaxID + if group { + groupMemberCode := customer.Identities[0].Code.String() + return &VatData{ + CustomerTaxNumber: &CustomerTaxNumber{ + TaxPayerID: taxID.Code.String()[0:8], + VatCode: taxID.Code.String()[8:9], + CountyCode: taxID.Code.String()[9:11], + GroupMemberTaxNumber: NewHungarianTaxNumber(groupMemberCode), + }, + } + } + return &VatData{ + CustomerTaxNumber: &CustomerTaxNumber{ + TaxPayerID: taxID.Code.String()[0:8], + VatCode: taxID.Code.String()[8:9], + CountyCode: taxID.Code.String()[9:11], + }, + } +} + +var europeanCountryCodes = []l10n.Code{ + l10n.AT, l10n.BE, l10n.BG, l10n.CY, l10n.CZ, l10n.DE, l10n.DK, l10n.EE, l10n.EL, l10n.ES, + l10n.FI, l10n.FR, l10n.HR, l10n.HU, l10n.IE, l10n.IT, l10n.LT, l10n.LU, l10n.LV, l10n.MT, + l10n.NL, l10n.PL, l10n.PT, l10n.RO, l10n.SE, l10n.SI, l10n.SK, l10n.XI, +} + +func isEuropeanCountryCode(code l10n.Code) bool { + for _, c := range europeanCountryCodes { + if c == code { + return true + } + } + return false +} diff --git a/head.go b/head.go index 85b10aa..a8dcb6d 100644 --- a/head.go +++ b/head.go @@ -4,7 +4,7 @@ import "github.com/invopop/gobl/bill" type InvoiceHead struct { SupplierInfo *SupplierInfo `xml:"supplierInfo"` - //CustomerInfo CustomerInfo `xml:"customerInfo,omitempty"` + CustomerInfo *CustomerInfo `xml:"customerInfo,omitempty"` //FiscalRepresentativeInfo FiscalRepresentativeInfo `xml:"fiscalRepresentativeInfo,omitempty"` InvoiceDetail *InvoiceDetail `xml:"invoiceDetail"` diff --git a/summary.go b/summary.go index c4f0902..124802d 100644 --- a/summary.go +++ b/summary.go @@ -2,6 +2,7 @@ package nav import ( "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/num" "github.com/invopop/gobl/tax" ) @@ -15,10 +16,10 @@ type InvoiceSummary struct { type SummaryNormal struct { SummaryByVatRate []*SummaryByVatRate `xml:"summaryByVatRate"` - InvoiceNetAmount float64 `xml:"invoiceNetAmount"` - InvoiceNetAmountHUF float64 `xml:"invoiceNetAmountHUF"` - InvoiceVatAmount float64 `xml:"invoiceVatAmount"` - InvoiceVatAmountHUF float64 `xml:"invoiceVatAmountHUF"` + InvoiceNetAmount string `xml:"invoiceNetAmount"` + InvoiceNetAmountHUF string `xml:"invoiceNetAmountHUF"` + InvoiceVatAmount string `xml:"invoiceVatAmount"` + InvoiceVatAmountHUF string `xml:"invoiceVatAmountHUF"` } type SummaryByVatRate struct { @@ -29,51 +30,57 @@ type SummaryByVatRate struct { } type VatRateNetData struct { - VatRateNetAmount float64 `xml:"vatRateNetAmount"` - VatRateNetAmountHUF float64 `xml:"vatRateNetAmountHUF"` + VatRateNetAmount string `xml:"vatRateNetAmount"` + VatRateNetAmountHUF string `xml:"vatRateNetAmountHUF"` } type VatRateVatData struct { - VatRateVatAmount float64 `xml:"vatRateVatAmount"` - VatRateVatAmountHUF float64 `xml:"vatRateVatAmountHUF"` + VatRateVatAmount string `xml:"vatRateVatAmount"` + VatRateVatAmountHUF string `xml:"vatRateVatAmountHUF"` } func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex float64) *SummaryByVatRate { return &SummaryByVatRate{ VatRate: NewVatRate(rate, info), VatRateNetData: &VatRateNetData{ - VatRateNetAmount: rate.Base.Float64(), - VatRateNetAmountHUF: rate.Base.Float64() * ex, + VatRateNetAmount: rate.Base.Rescale(2).String(), + VatRateNetAmountHUF: amountToHUF(rate.Base, ex), }, VatRateVatData: &VatRateVatData{ - VatRateVatAmount: rate.Amount.Float64(), - VatRateVatAmountHUF: rate.Amount.Float64() * ex, + VatRateVatAmount: rate.Amount.Rescale(2).String(), + VatRateVatAmountHUF: amountToHUF(rate.Amount, ex), }, } } func NewInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { vat := inv.Totals.Taxes.Category(tax.CategoryVAT) - totalVat := 0.0 + totalVat := num.MakeAmount(0, 5) summaryVat := []*SummaryByVatRate{} taxInfo := newTaxInfo(inv) ex, err := getInvoiceRate(inv) + if err != nil { return nil, err } for _, rate := range vat.Rates { summaryVat = append(summaryVat, newSummaryByVatRate(rate, taxInfo, ex)) - totalVat += rate.Amount.Float64() + totalVat = totalVat.Add(rate.Amount) } return &InvoiceSummary{ SummaryNormal: &SummaryNormal{ SummaryByVatRate: summaryVat, - InvoiceNetAmount: inv.Totals.Total.Float64(), - InvoiceNetAmountHUF: inv.Totals.Total.Float64() * ex, - InvoiceVatAmount: totalVat, - InvoiceVatAmountHUF: totalVat * ex, + InvoiceNetAmount: inv.Totals.Total.Rescale(2).String(), + InvoiceNetAmountHUF: amountToHUF(inv.Totals.Total, ex), + InvoiceVatAmount: totalVat.Rescale(2).String(), + InvoiceVatAmountHUF: amountToHUF(totalVat, ex), }, }, nil } + +func amountToHUF(amount num.Amount, ex float64) string { + result := amount.Multiply(num.AmountFromFloat64(ex, 5)) + return result.Rescale(2).String() +} diff --git a/supplier.go b/supplier.go index 1ea434f..55c8cde 100644 --- a/supplier.go +++ b/supplier.go @@ -6,23 +6,34 @@ import ( ) type SupplierInfo struct { - SupplierTaxNumber *TaxNumber `xml:"supplierTaxNumber"` - //GroupMemberTaxNumber TaxNumber `xml:"groupMemberTaxNumber,omitempty"` - //CommunityVATNumber string `xml:"communityVATNumber,omitempty"` + SupplierTaxNumber *TaxNumber `xml:"supplierTaxNumber"` + GroupMemberTaxNumber *TaxNumber `xml:"groupMemberTaxNumber,omitempty"` + //CommunityVATNumber string `xml:"communityVATNumber,omitempty"` // This is just the same as Supplier Number is HU SupplierName string `xml:"supplierName"` SupplierAddress *Address `xml:"supplierAddress"` - //SupplierBankAccount string `xml:"supplierBankAccount,omitempty"` + //SupplierBankAccount string `xml:"supplierBankAccount,omitempty"` // Not generally used //IndividualExemption bool `xml:"individualExemption,omitempty"` //ExciseLicenceNum string `xml:"exciseLicenceNum,omitempty"` } func NewSupplierInfo(supplier *org.Party) (*SupplierInfo, error) { - if supplier.TaxID.Country != l10n.HU.Tax() { + taxId := supplier.TaxID + if taxId.Country != l10n.HU.Tax() { return nil, ErrNotHungarian } + if taxId.Code.String()[8:9] != "5" { + return &SupplierInfo{ + SupplierTaxNumber: NewTaxNumber(taxId), + SupplierName: supplier.Name, + SupplierAddress: NewAddress(supplier.Addresses[0]), + }, nil + } + groupMemberCode := supplier.Identities[0].Code.String() return &SupplierInfo{ - SupplierTaxNumber: NewTaxNumber(supplier.TaxID), - SupplierName: supplier.Name, - SupplierAddress: NewAddress(supplier.Addresses[0]), + SupplierTaxNumber: NewTaxNumber(taxId), + GroupMemberTaxNumber: NewHungarianTaxNumber(groupMemberCode), + SupplierName: supplier.Name, + SupplierAddress: NewAddress(supplier.Addresses[0]), }, nil + } diff --git a/taxnumber.go b/taxnumber.go index ed626be..e86ede3 100644 --- a/taxnumber.go +++ b/taxnumber.go @@ -1,18 +1,34 @@ package nav -import "github.com/invopop/gobl/tax" +import ( + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/tax" +) type TaxNumber struct { TaxPayerID string `xml:"base:taxpayerId"` - //VatCode string `xml:"base:vatCode,omitempty"` - //CountyCode string `xml:"base:countyCode,omitempty"` + VatCode string `xml:"base:vatCode,omitempty"` + CountyCode string `xml:"base:countyCode,omitempty"` } // Have to look at the vatcodes for the regime // NewTaxNumber creates a new TaxNumber from a taxid func NewTaxNumber(taxid *tax.Identity) *TaxNumber { + if taxid.Country == l10n.HU.Tax() { + // Validate here or in validation: Only valid vat codes are 1,2,3 and 5 for the tax id (for the group could be 4) + return NewHungarianTaxNumber(taxid.Code.String()) + } else { + return &TaxNumber{ + TaxPayerID: taxid.String(), + } + } +} + +func NewHungarianTaxNumber(code string) *TaxNumber { return &TaxNumber{ - TaxPayerID: taxid.Code.String(), + TaxPayerID: code[:8], + VatCode: code[8:9], + CountyCode: code[9:11], } } diff --git a/vatrate.go b/vatrate.go index cc56c74..d1a3b2a 100644 --- a/vatrate.go +++ b/vatrate.go @@ -40,6 +40,9 @@ type taxInfo struct { // NewVatRate creates a new VatRate from a taxid func NewVatRate(rate *tax.RateTotal, info *taxInfo) *VatRate { + if rate.Key != tax.RateExempt && rate.Key != tax.RateZero { + return &VatRate{VatPercentage: rate.Percent.Amount().Float64()} + } if rate.Key == tax.RateExempt { if info.outOfScope { // Q: Is there a way in GOBL to access the extension names? From 749ae93fc4f8c1c2f940708e2274d0d6cf243d17 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Wed, 14 Aug 2024 13:45:29 +0000 Subject: [PATCH 04/27] Adding tests --- README.md | 10 ++- address.go | 33 ++++++++-- address_test.go | 71 ++++++++++++++++++++ customer.go | 80 +++++++++++----------- customer_test.go | 165 ++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 9 ++- invoice.go | 9 +-- nav.go | 13 ++-- summary.go | 10 ++- supplier.go | 23 ++++--- supplier_test.go | 86 ++++++++++++++++++++++++ taxnumber.go | 30 ++++++--- taxnumber_test.go | 119 +++++++++++++++++++++++++++++++++ vatrate.go | 10 +-- 14 files changed, 576 insertions(+), 92 deletions(-) create mode 100644 address_test.go create mode 100644 customer_test.go create mode 100644 supplier_test.go create mode 100644 taxnumber_test.go diff --git a/README.md b/README.md index 19860bd..85df9a9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # gobl.hu-nav Convert GOBL into Hungarian NAV XML documents +The invoice data content of the data report must be embedded, encoded in BASE64 format, in the ManageInvoiceRequest/invoiceoperations/invoiceOperation/InvoiceData element. + ## Things to include in validation: - If the 9th digit of the tax id is 5, the group member tax id must exist and should be 4. If the Vat Status is Domestic (Hungarian) always a vat id -- The 9th digit of the Vat Ids must be 1,2,3 or 5 and of the member groups must be 4. \ No newline at end of file +- The 9th digit of the Vat Ids must be 1,2,3 or 5 and of the member groups must be 4. + +## Limitations + +- We don't support batch invoicing (It is used only for batch modifications) +- We don't support simplified invoices for the moment +- We don't support modification of invoices \ No newline at end of file diff --git a/address.go b/address.go index 4d7a1b7..ef19c80 100644 --- a/address.go +++ b/address.go @@ -7,12 +7,12 @@ import ( ) type Address struct { - SimpleAddress SimpleAddress `xml:"base:simpleAddress,omitempty"` - //DetailedAddress DetailedAddress `xml:"base:detailedAddress,omitempty"` + SimpleAddress *SimpleAddress `xml:"base:simpleAddress,omitempty"` + DetailedAddress *DetailedAddress `xml:"base:detailedAddress,omitempty"` } // DetailedAddressType represents detailed address data -/*type DetailedAddress struct { +type DetailedAddress struct { CountryCode string `xml:"base:countryCode"` Region string `xml:"base:region,omitempty"` PostalCode string `xml:"base:postalCode"` @@ -25,15 +25,15 @@ type Address struct { Floor string `xml:"base:floor,omitempty"` Door string `xml:"base:door,omitempty"` LotNumber string `xml:"base:lotNumber,omitempty"` -}*/ +} // GOBL does not support dividing the address into public place category and street name // For the moment we can use SimpleAddress // SimpleAddressType represents a simple address type SimpleAddress struct { - CountryCode string `xml:"countryCode"` - //Region string `xml:"region,omitempty"` + CountryCode string `xml:"countryCode"` + Region string `xml:"region,omitempty"` PostalCode string `xml:"base:postalCode"` City string `xml:"base:city"` AdditionalAddressDetail string `xml:"base:additionalAddressDetail"` @@ -41,15 +41,34 @@ type SimpleAddress struct { func NewAddress(address *org.Address) *Address { return &Address{ - SimpleAddress: SimpleAddress{ + DetailedAddress: NewDetailedAddress(address), + } + /*return &Address{ + SimpleAddress: &SimpleAddress{ CountryCode: address.Country.String(), PostalCode: address.Code, City: address.Locality, AdditionalAddressDetail: formatAddress(address), }, + }*/ +} + +func NewDetailedAddress(address *org.Address) *DetailedAddress { + return &DetailedAddress{ + CountryCode: address.Country.String(), + Region: address.Region, + PostalCode: address.Code, + City: address.Locality, + StreetName: address.Street, + Number: address.Number, + Building: address.Block, + Floor: address.Floor, + Door: address.Door, + PublicPlaceCategory: "utca", //address.StreetType, //Waiting for PR to be approved } } +// This is used only for SimpleAddress func formatAddress(address *org.Address) string { if address.PostOfficeBox != "" { return "PO Box / Apdo " + address.PostOfficeBox diff --git a/address_test.go b/address_test.go new file mode 100644 index 0000000..42b9216 --- /dev/null +++ b/address_test.go @@ -0,0 +1,71 @@ +package nav + +import ( + "testing" + + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/org" + "github.com/stretchr/testify/assert" +) + +func TestNewAddress(t *testing.T) { + tests := []struct { + name string + input *org.Address + expectedOutput *Address + }{ + { + name: "Detailed address with all fields", + input: &org.Address{ + Country: l10n.HU.ISO(), + Region: "Budapest", + Code: "1234", + Locality: "Budapest", + Street: "Main Street", + Number: "10", + Block: "B", + Floor: "2", + Door: "5", + }, + expectedOutput: &Address{ + DetailedAddress: &DetailedAddress{ + CountryCode: "HU", + Region: "Budapest", + PostalCode: "1234", + City: "Budapest", + StreetName: "Main Street", + Number: "10", + Building: "B", + Floor: "2", + Door: "5", + PublicPlaceCategory: "utca", + }, + }, + }, + { + name: "Detailed address with missing optional fields", + input: &org.Address{ + Country: l10n.HU.ISO(), + Code: "1234", + Locality: "Budapest", + Street: "Main Street", + }, + expectedOutput: &Address{ + DetailedAddress: &DetailedAddress{ + CountryCode: "HU", + PostalCode: "1234", + City: "Budapest", + StreetName: "Main Street", + PublicPlaceCategory: "utca", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewAddress(tt.input) + assert.Equal(t, tt.expectedOutput, result) + }) + } +} diff --git a/customer.go b/customer.go index 3136269..fdc05bb 100644 --- a/customer.go +++ b/customer.go @@ -1,7 +1,6 @@ package nav import ( - "github.com/invopop/gobl/bill" "github.com/invopop/gobl/l10n" "github.com/invopop/gobl/org" "github.com/invopop/gobl/tax" @@ -28,51 +27,48 @@ type CustomerTaxNumber struct { GroupMemberTaxNumber *TaxNumber `xml:"groupMemberTaxNumber,omitempty"` } -func NewCustomerInfo(inv *bill.Invoice) *CustomerInfo { +func NewCustomerInfo(customer *org.Party) (*CustomerInfo, error) { - customer := inv.Customer - - if inv.Tax.ContainsTag(hu.TagPrivatePerson) { - return &CustomerInfo{ - CustomerVatStatus: "PRIVATE_PERSON", - CustomerName: customer.Name, - CustomerAddress: NewAddress(customer.Addresses[0]), - } - } - - // If the customer is not a taxable person - if customer.TaxID == nil { + taxID := customer.TaxID + if taxID == nil { return &CustomerInfo{ CustomerVatStatus: "OTHER", CustomerName: customer.Name, CustomerAddress: NewAddress(customer.Addresses[0]), - } + }, nil } - - taxID := customer.TaxID - group := false status := "OTHER" if taxID.Country == l10n.HU.Tax() { - status = "DOMESTIC" - // One case for group Id and other for simple (Group ID has the 9th character as 5) - if taxID.Code.String()[8:9] == "5" { - group = true + if taxID.Code.String() == "" || (taxID.Code.String()[0:1] == "8" && len(taxID.Code) == 10) { + return &CustomerInfo{ + CustomerVatStatus: "PRIVATE_PERSON", + CustomerName: customer.Name, + CustomerAddress: NewAddress(customer.Addresses[0]), + }, nil } + status = "DOMESTIC" + + } + + vatData, err := newVatData(customer, status) + if err != nil { + return nil, err } + return &CustomerInfo{ CustomerVatStatus: status, - CustomerVatData: newVatData(customer, group, status), + CustomerVatData: vatData, CustomerName: customer.Name, CustomerAddress: NewAddress(customer.Addresses[0]), - } + }, nil } -func newVatData(customer *org.Party, group bool, status string) *VatData { +func newVatData(customer *org.Party, status string) (*VatData, error) { if status == "OTHER" { - return newOtherVatData(customer.TaxID) + return newOtherVatData(customer.TaxID), nil } - return newDomesticVatData(customer, group) + return newDomesticVatData(customer) } func newOtherVatData(taxID *tax.Identity) *VatData { @@ -86,26 +82,30 @@ func newOtherVatData(taxID *tax.Identity) *VatData { } } -func newDomesticVatData(customer *org.Party, group bool) *VatData { - taxID := customer.TaxID - if group { - groupMemberCode := customer.Identities[0].Code.String() +func newDomesticVatData(customer *org.Party) (*VatData, error) { + taxNumber, groupNumber, err := NewTaxNumber(customer) + if err != nil { + return nil, err + } + + if groupNumber != nil { return &VatData{ CustomerTaxNumber: &CustomerTaxNumber{ - TaxPayerID: taxID.Code.String()[0:8], - VatCode: taxID.Code.String()[8:9], - CountyCode: taxID.Code.String()[9:11], - GroupMemberTaxNumber: NewHungarianTaxNumber(groupMemberCode), + TaxPayerID: taxNumber.TaxPayerID, + VatCode: taxNumber.VatCode, + CountyCode: taxNumber.CountyCode, + GroupMemberTaxNumber: groupNumber, }, - } + }, nil } + return &VatData{ CustomerTaxNumber: &CustomerTaxNumber{ - TaxPayerID: taxID.Code.String()[0:8], - VatCode: taxID.Code.String()[8:9], - CountyCode: taxID.Code.String()[9:11], + TaxPayerID: taxNumber.TaxPayerID, + VatCode: taxNumber.VatCode, + CountyCode: taxNumber.CountyCode, }, - } + }, nil } var europeanCountryCodes = []l10n.Code{ diff --git a/customer_test.go b/customer_test.go new file mode 100644 index 0000000..26d5466 --- /dev/null +++ b/customer_test.go @@ -0,0 +1,165 @@ +package nav + +import ( + "reflect" + "testing" + + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" +) + +func TestNewCustomerInfo(t *testing.T) { + tests := []struct { + name string + customer *org.Party + expectedStatus string + expectedVatData *VatData + expectedName string + expectedErr error + }{ + { + name: "Customer with no TaxID", + customer: &org.Party{ + Name: "John Doe", + Addresses: []*org.Address{ + { + Country: l10n.HU.ISO(), + Code: "1234", + Locality: "Budapest", + Street: "Main Street", + }, + }, + }, + expectedStatus: "OTHER", + expectedVatData: nil, + expectedName: "John Doe", + expectedErr: nil, + }, + { + name: "Hungarian Private Person TaxID", + customer: &org.Party{ + Name: "Private Person", + TaxID: &tax.Identity{ + Country: l10n.HU.Tax(), + Code: "8123456789", // Indicates a private person + }, + Addresses: []*org.Address{ + { + Country: l10n.HU.ISO(), + Code: "1234", + Locality: "Budapest", + Street: "Main Street", + }, + }, + }, + expectedStatus: "PRIVATE_PERSON", + expectedVatData: nil, + expectedName: "Private Person", + expectedErr: nil, + }, + { + name: "Hungarian Domestic VAT Status", + customer: &org.Party{ + Name: "Hungarian Company", + TaxID: &tax.Identity{ + Country: l10n.HU.Tax(), + Code: "12345678501", + }, + Identities: []*org.Identity{ + {Code: "12345678402"}, + }, + Addresses: []*org.Address{ + { + Country: l10n.HU.ISO(), + Code: "1234", + Locality: "Budapest", + Street: "Main Street", + }, + }, + }, + expectedStatus: "DOMESTIC", + expectedVatData: &VatData{ + CustomerTaxNumber: &CustomerTaxNumber{ + TaxPayerID: "12345678", + VatCode: "5", + CountyCode: "01", + GroupMemberTaxNumber: &TaxNumber{ + TaxPayerID: "12345678", + VatCode: "4", + CountyCode: "02", + }, + }, + }, + expectedName: "Hungarian Company", + expectedErr: nil, + }, + { + name: "EU Country VAT Status", + customer: &org.Party{ + Name: "European Company", + TaxID: &tax.Identity{ + Country: l10n.DE.Tax(), + Code: "123456789", + }, + Addresses: []*org.Address{ + { + Country: l10n.HU.ISO(), + Code: "1234", + Locality: "Budapest", + Street: "Main Street", + }, + }, + }, + expectedStatus: "OTHER", + expectedVatData: &VatData{ + CommunityVATNumber: "DE123456789", + }, + expectedName: "European Company", + expectedErr: nil, + }, + { + name: "Non-EU (Third State) VAT Status", + customer: &org.Party{ + Name: "Non-EU Company", + TaxID: &tax.Identity{ + Country: l10n.US.Tax(), + Code: "123456789", + }, + Addresses: []*org.Address{ + { + Country: l10n.HU.ISO(), + Code: "1234", + Locality: "Budapest", + Street: "Main Street", + }, + }, + }, + expectedStatus: "OTHER", + expectedVatData: &VatData{ + ThirdStateTaxId: "US123456789", + }, + expectedName: "Non-EU Company", + expectedErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + customerInfo, err := NewCustomerInfo(tt.customer) + + if err != tt.expectedErr { + t.Errorf("expected error %v, got %v", tt.expectedErr, err) + } + if customerInfo.CustomerVatStatus != tt.expectedStatus { + t.Errorf("expected status %v, got %v", tt.expectedStatus, customerInfo.CustomerVatStatus) + } + if !reflect.DeepEqual(customerInfo.CustomerVatData, tt.expectedVatData) { + t.Errorf("expected VAT data %v, got %v", tt.expectedVatData, customerInfo.CustomerVatData) + } + if customerInfo.CustomerName != tt.expectedName { + t.Errorf("expected name %v, got %v", tt.expectedName, customerInfo.CustomerName) + } + }) + } +} diff --git a/go.mod b/go.mod index fbfa51f..87c8731 100644 --- a/go.mod +++ b/go.mod @@ -2,20 +2,23 @@ module github.com/invopop/gobl.hu-nav go 1.22.3 -require github.com/invopop/gobl v0.113.0 +require ( + github.com/invopop/gobl v0.113.0 + github.com/stretchr/testify v1.8.4 +) require ( cloud.google.com/go v0.110.2 // indirect - github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/invopop/jsonschema v0.12.0 // indirect github.com/invopop/validation v0.7.0 // indirect github.com/invopop/yaml v0.3.1 // indirect - github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect golang.org/x/crypto v0.22.0 // indirect diff --git a/invoice.go b/invoice.go index 1fa3ee4..a9ded0f 100644 --- a/invoice.go +++ b/invoice.go @@ -6,10 +6,11 @@ import "github.com/invopop/gobl/bill" // For the moment, we are only going to focus on invoice type InvoiceMain struct { Invoice *Invoice `xml:"invoice"` + //BatchInvoice *BatchInvoice `xml:"batchInvoice"` // Used only for batch modifications } type Invoice struct { - //InvoiceReference InvoiceReference `xml:"invoiceReference,omitempty"` + //InvoiceReference InvoiceReference `xml:"invoiceReference,omitempty"` // Used for invoice modification InvoiceHead *InvoiceHead `xml:"invoiceHead"` //InvoiceLines InvoiceLines `xml:"invoiceLines,omitempty"` //ProductFeeSummary ProductFeeSummary `xml:"productFeeSummary,omitempty"` @@ -23,15 +24,15 @@ func NewInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { return nil, err } - invoiceSummary, err := NewInvoiceSummary(inv) + //invoiceSummary, err := NewInvoiceSummary(inv) if err != nil { return nil, err } return &InvoiceMain{ Invoice: &Invoice{ - InvoiceHead: invoiceHead, - InvoiceSummary: invoiceSummary, + InvoiceHead: invoiceHead, + //InvoiceSummary: invoiceSummary, }, }, nil } diff --git a/nav.go b/nav.go index 2613089..2ab6328 100644 --- a/nav.go +++ b/nav.go @@ -13,8 +13,9 @@ xmlns:common="http://schemas.nav.gov.hu/NTCA/1.0/common" xmlns:base="http://sche // Standard error responses. var ( - ErrNotHungarian = newValidationError("only hungarian invoices are supported") - ErrNoExchangeRate = newValidationError("no exchange rate to HUF found") + ErrNotHungarian = newValidationError("only hungarian invoices are supported") + ErrNoExchangeRate = newValidationError("no exchange rate to HUF found") + ErrInvalidGroupMemberCode = newValidationError("invalid group member code") ) // ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed @@ -35,10 +36,10 @@ func newValidationError(text string) error { type Document struct { XMLName xml.Name `xml:"InvoiceData"` XMLNS string `xml:"xmlns,attr"` - //XMLNSXsi string `xml:"xmlns:xsi,attr"` - //XSISchema string `xml:"xsi:schemaLocation,attr"` - //XMLNSCommon string `xml:"xmlns:common,attr"` - //XMLNSBase string `xml:"xmlns:base,attr"` + //XMLNSXsi string `xml:"xmlns:xsi,attr"` + //XSISchema string `xml:"xsi:schemaLocation,attr"` + //XMLNSCommon string `xml:"xmlns:common,attr"` + //XMLNSBase string `xml:"xmlns:base,attr"` InvoiceNumber string `xml:"invoiceNumber"` InvoiceIssueDate string `xml:"invoiceIssueDate"` CompletenessIndicator bool `xml:"completenessIndicator"` // Indicates whether the data report is the invoice itself diff --git a/summary.go b/summary.go index 124802d..7491610 100644 --- a/summary.go +++ b/summary.go @@ -1,9 +1,7 @@ package nav import ( - "github.com/invopop/gobl/bill" "github.com/invopop/gobl/num" - "github.com/invopop/gobl/tax" ) // Depends wether the invoice is simplified or not @@ -39,7 +37,7 @@ type VatRateVatData struct { VatRateVatAmountHUF string `xml:"vatRateVatAmountHUF"` } -func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex float64) *SummaryByVatRate { +/*func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex float64) *SummaryByVatRate { return &SummaryByVatRate{ VatRate: NewVatRate(rate, info), VatRateNetData: &VatRateNetData{ @@ -51,9 +49,9 @@ func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex float64) *Summar VatRateVatAmountHUF: amountToHUF(rate.Amount, ex), }, } -} +}*/ -func NewInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { +/*func NewInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { vat := inv.Totals.Taxes.Category(tax.CategoryVAT) totalVat := num.MakeAmount(0, 5) summaryVat := []*SummaryByVatRate{} @@ -78,7 +76,7 @@ func NewInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { }, }, nil -} +}*/ func amountToHUF(amount num.Amount, ex float64) string { result := amount.Multiply(num.AmountFromFloat64(ex, 5)) diff --git a/supplier.go b/supplier.go index 55c8cde..7112e2d 100644 --- a/supplier.go +++ b/supplier.go @@ -12,7 +12,7 @@ type SupplierInfo struct { SupplierName string `xml:"supplierName"` SupplierAddress *Address `xml:"supplierAddress"` //SupplierBankAccount string `xml:"supplierBankAccount,omitempty"` // Not generally used - //IndividualExemption bool `xml:"individualExemption,omitempty"` + //IndividualExemption bool `xml:"individualExemption,omitempty"` // Value is "true" if the seller has individual VAT exempt status //ExciseLicenceNum string `xml:"exciseLicenceNum,omitempty"` } @@ -21,19 +21,22 @@ func NewSupplierInfo(supplier *org.Party) (*SupplierInfo, error) { if taxId.Country != l10n.HU.Tax() { return nil, ErrNotHungarian } - if taxId.Code.String()[8:9] != "5" { + taxNumber, groupNumber, err := NewTaxNumber(supplier) + if err != nil { + return nil, err + } + if groupNumber != nil { return &SupplierInfo{ - SupplierTaxNumber: NewTaxNumber(taxId), - SupplierName: supplier.Name, - SupplierAddress: NewAddress(supplier.Addresses[0]), + SupplierTaxNumber: taxNumber, + GroupMemberTaxNumber: groupNumber, + SupplierName: supplier.Name, + SupplierAddress: NewAddress(supplier.Addresses[0]), }, nil } - groupMemberCode := supplier.Identities[0].Code.String() return &SupplierInfo{ - SupplierTaxNumber: NewTaxNumber(taxId), - GroupMemberTaxNumber: NewHungarianTaxNumber(groupMemberCode), - SupplierName: supplier.Name, - SupplierAddress: NewAddress(supplier.Addresses[0]), + SupplierTaxNumber: taxNumber, + SupplierName: supplier.Name, + SupplierAddress: NewAddress(supplier.Addresses[0]), }, nil } diff --git a/supplier_test.go b/supplier_test.go new file mode 100644 index 0000000..85152db --- /dev/null +++ b/supplier_test.go @@ -0,0 +1,86 @@ +package nav + +import ( + "testing" + + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSupplierInfo(t *testing.T) { + // Test case 1: Valid Hungarian supplier without group number + supplier := &org.Party{ + TaxID: &tax.Identity{ + Country: l10n.HU.Tax(), + Code: "98109858", + }, + Name: "Test Supplier", + Addresses: []*org.Address{ + { + Country: l10n.HU.ISO(), + Code: "1234", + Locality: "Budapest", + Street: "Main Street", + }, + }, + } + taxNumber := &TaxNumber{TaxPayerID: "98109858"} + groupNumber := (*TaxNumber)(nil) + + supplierInfo, err := NewSupplierInfo(supplier) + require.NoError(t, err) + assert.NotNil(t, supplierInfo) + assert.Equal(t, taxNumber, supplierInfo.SupplierTaxNumber) + assert.Nil(t, supplierInfo.GroupMemberTaxNumber) + assert.Equal(t, "Test Supplier", supplierInfo.SupplierName) + assert.NotNil(t, supplierInfo.SupplierAddress) + + // Test case 2: Non-Hungarian supplier + nonHUSupplier := &org.Party{ + TaxID: &tax.Identity{ + Country: l10n.GB.Tax(), // GB for Great Britain + Code: "87654321", + }, + Name: "Non-Hungarian Supplier", + } + + supplierInfo, err = NewSupplierInfo(nonHUSupplier) + require.Error(t, err) + assert.Nil(t, supplierInfo) + assert.Equal(t, ErrNotHungarian, err) + + supplier = &org.Party{ + TaxID: &tax.Identity{ + Country: l10n.HU.Tax(), + Code: "88212131503", + }, + Name: "Test Supplier", + Addresses: []*org.Address{ + { + Country: l10n.HU.ISO(), + Code: "1234", + Locality: "Budapest", + Street: "Main Street", + }, + }, + Identities: []*org.Identity{ + { + Code: "21114445423", + }, + }, + } + taxNumber = &TaxNumber{TaxPayerID: "88212131", VatCode: "5", CountyCode: "03"} + groupNumber = &TaxNumber{TaxPayerID: "21114445", VatCode: "4", CountyCode: "23"} + + supplierInfo, err = NewSupplierInfo(supplier) + + require.NoError(t, err) + assert.NotNil(t, supplierInfo) + assert.Equal(t, taxNumber, supplierInfo.SupplierTaxNumber) + assert.Equal(t, groupNumber, supplierInfo.GroupMemberTaxNumber) + assert.Equal(t, "Test Supplier", supplierInfo.SupplierName) + assert.NotNil(t, supplierInfo.SupplierAddress) +} diff --git a/taxnumber.go b/taxnumber.go index e86ede3..61c61ae 100644 --- a/taxnumber.go +++ b/taxnumber.go @@ -2,7 +2,7 @@ package nav import ( "github.com/invopop/gobl/l10n" - "github.com/invopop/gobl/tax" + "github.com/invopop/gobl/org" ) type TaxNumber struct { @@ -14,15 +14,29 @@ type TaxNumber struct { // Have to look at the vatcodes for the regime // NewTaxNumber creates a new TaxNumber from a taxid -func NewTaxNumber(taxid *tax.Identity) *TaxNumber { - if taxid.Country == l10n.HU.Tax() { - // Validate here or in validation: Only valid vat codes are 1,2,3 and 5 for the tax id (for the group could be 4) - return NewHungarianTaxNumber(taxid.Code.String()) - } else { - return &TaxNumber{ - TaxPayerID: taxid.String(), +func NewTaxNumber(party *org.Party) (*TaxNumber, *TaxNumber, error) { + taxID := party.TaxID + if taxID.Country == l10n.HU.Tax() { + // If the vat code is 5, then the group member code should be 4 + if len(taxID.Code) == 11 { + if taxID.Code.String()[8:9] == "5" { + groupMemberCode := party.Identities[0].Code.String() + if len(groupMemberCode) != 11 || groupMemberCode[8:9] != "4" { + return nil, nil, ErrInvalidGroupMemberCode + } + return NewHungarianTaxNumber(taxID.Code.String()), + NewHungarianTaxNumber(groupMemberCode), nil + } + return NewHungarianTaxNumber(taxID.Code.String()), nil, nil } + return &TaxNumber{ + TaxPayerID: taxID.Code.String(), + }, nil, nil } + return &TaxNumber{ + TaxPayerID: taxID.String(), + }, nil, nil + } func NewHungarianTaxNumber(code string) *TaxNumber { diff --git a/taxnumber_test.go b/taxnumber_test.go new file mode 100644 index 0000000..12b41f0 --- /dev/null +++ b/taxnumber_test.go @@ -0,0 +1,119 @@ +package nav + +import ( + "reflect" + "testing" + + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" +) + +func TestNewTaxNumber(t *testing.T) { + tests := []struct { + name string + party *org.Party + expectedMain *TaxNumber + expectedGroup *TaxNumber + expectedErr error + }{ + { + name: "Non-Hungarian TaxID", + party: &org.Party{ + TaxID: &tax.Identity{ + Country: l10n.ES.Tax(), // Non-Hungarian country code + Code: "1234567890", + }, + }, + expectedMain: &TaxNumber{ + TaxPayerID: "ES1234567890", + }, + expectedGroup: nil, + expectedErr: nil, + }, + { + name: "Hungarian TaxID with VatCode 5 and valid group member code", + party: &org.Party{ + TaxID: &tax.Identity{ + Country: l10n.HU.Tax(), + Code: "12345678501", + }, + Identities: []*org.Identity{ + {Code: "12345678402"}, + }, + }, + expectedMain: &TaxNumber{ + TaxPayerID: "12345678", + VatCode: "5", + CountyCode: "01", + }, + expectedGroup: &TaxNumber{ + TaxPayerID: "12345678", + VatCode: "4", + CountyCode: "02", + }, + expectedErr: nil, + }, + { + name: "Hungarian TaxID with VatCode 5 and invalid group member code", + party: &org.Party{ + TaxID: &tax.Identity{ + Country: l10n.HU.Tax(), + Code: "12345678501", + }, + Identities: []*org.Identity{ + {Code: "12345678303"}, + }, + }, + expectedMain: nil, + expectedGroup: nil, + expectedErr: ErrInvalidGroupMemberCode, + }, + { + name: "Hungarian TaxID with other VatCode", + party: &org.Party{ + TaxID: &tax.Identity{ + Country: l10n.HU.Tax(), + Code: "12345678301", + }, + }, + expectedMain: &TaxNumber{ + TaxPayerID: "12345678", + VatCode: "3", + CountyCode: "01", + }, + expectedGroup: nil, + expectedErr: nil, + }, + { + name: "Hungarian TaxID with short code", + party: &org.Party{ + TaxID: &tax.Identity{ + Country: l10n.HU.Tax(), + Code: "12345678", + }, + }, + expectedMain: &TaxNumber{ + TaxPayerID: "12345678", + }, + expectedGroup: nil, + expectedErr: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mainTaxNum, groupTaxNum, err := NewTaxNumber(tt.party) + + if err != tt.expectedErr { + t.Errorf("expected error %v, got %v", tt.expectedErr, err) + } + if !reflect.DeepEqual(mainTaxNum, tt.expectedMain) { + t.Errorf("expected mainTaxNum %v, got %v", tt.expectedMain, mainTaxNum) + } + if !reflect.DeepEqual(groupTaxNum, tt.expectedGroup) { + t.Errorf("expected groupTaxNum %v, got %v", tt.expectedGroup, groupTaxNum) + } + }) + } +} diff --git a/vatrate.go b/vatrate.go index d1a3b2a..a128d27 100644 --- a/vatrate.go +++ b/vatrate.go @@ -1,10 +1,6 @@ package nav -import ( - "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/regimes/hu" - "github.com/invopop/gobl/tax" -) +//"github.com/invopop/gobl/regimes/hu" // Vat Rate may contain exactly one of the 8 possible fields type VatRate struct { @@ -39,7 +35,7 @@ type taxInfo struct { } // NewVatRate creates a new VatRate from a taxid -func NewVatRate(rate *tax.RateTotal, info *taxInfo) *VatRate { +/*func NewVatRate(rate *tax.RateTotal, info *taxInfo) *VatRate { if rate.Key != tax.RateExempt && rate.Key != tax.RateZero { return &VatRate{VatPercentage: rate.Percent.Amount().Float64()} } @@ -93,4 +89,4 @@ func newTaxInfo(inv *bill.Invoice) *taxInfo { } } return info -} +}*/ From 14c11450d29cba686afee31127d3346a40fca0fc Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Thu, 15 Aug 2024 10:47:46 +0000 Subject: [PATCH 05/27] Adding detail testing and starting lines --- README.md | 5 ++- detail.go | 104 ++++++++++++++++++++++++++++++------------------- detail_test.go | 89 ++++++++++++++++++++++++++++++++++++++++++ head.go | 7 ++++ invoice.go | 4 +- lines.go | 37 ++++++++++++++++++ 6 files changed, 201 insertions(+), 45 deletions(-) create mode 100644 detail_test.go create mode 100644 lines.go diff --git a/README.md b/README.md index 85df9a9..8d8043c 100644 --- a/README.md +++ b/README.md @@ -10,5 +10,6 @@ The invoice data content of the data report must be embedded, encoded in BASE64 ## Limitations - We don't support batch invoicing (It is used only for batch modifications) -- We don't support simplified invoices for the moment -- We don't support modification of invoices \ No newline at end of file +- We don't support modification of invoices +- We don't support fiscal representatives +- We don't support aggregate invoices \ No newline at end of file diff --git a/detail.go b/detail.go index ded18e4..2bbb415 100644 --- a/detail.go +++ b/detail.go @@ -1,19 +1,17 @@ package nav import ( - "math" - "strconv" - "strings" - "github.com/invopop/gobl/bill" "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/tax" ) +// InvoiceDetail contains the invoice detail data type InvoiceDetail struct { - InvoiceCategory string `xml:"invoiceCategory"` //NORMAL, SIMPLIFIED, AGGREGATE - InvoiceDeliveryDate string `xml:"invoiceDeliveryDate"` - //InvoiceDeliveryPeriodStart string `xml:"invoiceDeliveryPeriodStart,omitempty"` - //InvoiceDeliveryPeriodEnd string `xml:"invoiceDeliveryPeriodEnd,omitempty"` + InvoiceCategory string `xml:"invoiceCategory"` //NORMAL, SIMPLIFIED, AGGREGATE + InvoiceDeliveryDate string `xml:"invoiceDeliveryDate"` + InvoiceDeliveryPeriodStart string `xml:"invoiceDeliveryPeriodStart,omitempty"` + InvoiceDeliveryPeriodEnd string `xml:"invoiceDeliveryPeriodEnd,omitempty"` //InvoiceAccountingDeliveryDate string `xml:"invoiceAccountingDeliveryDate,omitempty"` //PeriodicalSettlement bool `xml:"periodicalSettlement,omitempty"` //SmallBusinessIndicator bool `xml:"smallBusinessIndicator,omitempty"` @@ -21,8 +19,8 @@ type InvoiceDetail struct { ExchangeRate float64 `xml:"exchangeRate"` //UtilitySettlementIndicator bool `xml:"utilitySettlementIndicator,omitempty"` //SelfBillingIndicator bool `xml:"selfBillingIndicator,omitempty"` - //PaymentMethod string `xml:"paymentMethod,omitempty"` - //PaymentDate string `xml:"paymentDate,omitempty"` + PaymentMethod string `xml:"paymentMethod,omitempty"` + PaymentDate string `xml:"paymentDate,omitempty"` //CashAccountingIndicator bool `xml:"cashAccountingIndicator,omitempty"` InvoiceAppearance string `xml:"invoiceAppearance"` // PAPER, ELECTRONIC, EDI, UNKNOWN //Some more optional data @@ -30,17 +28,66 @@ type InvoiceDetail struct { // NewInvoiceDetail creates a new InvoiceDetail from an invoice func NewInvoiceDetail(inv *bill.Invoice) (*InvoiceDetail, error) { + category := "NORMAL" + if inv.Tax.ContainsTag(tax.TagSimplified) { + category = "SIMPLIFIED" + } + + date := &inv.IssueDate + periodStart := "" + periodEnd := "" + if inv.Delivery != nil { + if inv.Delivery.Date != nil { + date = inv.Delivery.Date + } + if inv.Delivery.Period != nil { + periodStart = inv.Delivery.Period.Start.String() + periodEnd = inv.Delivery.Period.End.String() + } + } + + dueDate := "" + paymentMethod := "" + if inv.Payment != nil { + if inv.Payment.Terms != nil { + if inv.Payment.Terms.DueDates != nil { + if len(inv.Payment.Terms.DueDates) > 0 { + dueDate = inv.Payment.Terms.DueDates[0].Date.String() + } + } + } + if inv.Payment.Instructions != nil { + switch inv.Payment.Instructions.Key { + case "cash": + paymentMethod = "CASH" + case "credit-transfer": + paymentMethod = "TRANSFER" + case "debit-transfer": + paymentMethod = "TRANSFER" + case "card": + paymentMethod = "CARD" + // There is one case that is VOUCHER + default: + paymentMethod = "OTHER" + } + } + } + rate, err := getInvoiceRate(inv) if err != nil { return nil, err } return &InvoiceDetail{ - InvoiceCategory: "NORMAL", - InvoiceDeliveryDate: inv.OperationDate.String(), - CurrencyCode: inv.Currency.String(), - ExchangeRate: formatRate(rate), - InvoiceAppearance: "EDI", + InvoiceCategory: category, + InvoiceDeliveryDate: date.String(), + InvoiceDeliveryPeriodStart: periodStart, + InvoiceDeliveryPeriodEnd: periodEnd, + CurrencyCode: inv.Currency.String(), + ExchangeRate: rate, + PaymentMethod: paymentMethod, + PaymentDate: dueDate, + InvoiceAppearance: "EDI", }, nil } @@ -51,34 +98,9 @@ func getInvoiceRate(inv *bill.Invoice) (float64, error) { for _, ex := range inv.ExchangeRates { if ex.To == currency.HUF { - return ex.Amount.Float64(), nil + return ex.Amount.Rescale(6).Float64(), nil } } return -1.0, ErrNoExchangeRate } - -func formatRate(value float64) float64 { - // Check if the float64 number has more than 6 decimal digits - if hasMoreThanSixDecimalDigits(value) { - return math.Round(value*1000000) / 1000000 - } - - // Convert the float64 number to a string without trailing zeros - return value -} - -// hasMoreThanSixDecimalDigits checks if a float64 number has more than 6 decimal digits -func hasMoreThanSixDecimalDigits(value float64) bool { - // Separate the fractional part from the integer part - fractionalPart := value - math.Floor(value) - - // Convert the fractional part to a string - fractionalStr := strconv.FormatFloat(fractionalPart, 'f', -1, 64) - - // Remove the leading "0." from the string representation - fractionalStr = strings.TrimPrefix(fractionalStr, "0.") - - // Check the length of the fractional part - return len(fractionalStr) > 6 -} diff --git a/detail_test.go b/detail_test.go new file mode 100644 index 0000000..a730f53 --- /dev/null +++ b/detail_test.go @@ -0,0 +1,89 @@ +package nav + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cal" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/pay" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestNewInvoiceDetail_NormalInvoice(t *testing.T) { + invoice := &bill.Invoice{ + Currency: currency.USD, + ExchangeRates: []*currency.ExchangeRate{ + { + From: currency.USD, + To: currency.HUF, + Amount: num.AmountFromFloat64(358.3543210123, 5), + }, + }, + IssueDate: *cal.NewDate(2023, 8, 10), + Payment: &bill.Payment{ + Terms: &pay.Terms{ + DueDates: []*pay.DueDate{ + {Date: cal.NewDate(2023, 8, 20)}, + }, + }, + Instructions: &pay.Instructions{ + Key: "card", + }, + }, + } + + detail, err := NewInvoiceDetail(invoice) + assert.NoError(t, err) + assert.Equal(t, "NORMAL", detail.InvoiceCategory) + assert.Equal(t, "2023-08-10", detail.InvoiceDeliveryDate) + assert.Equal(t, "", detail.InvoiceDeliveryPeriodStart) + assert.Equal(t, "", detail.InvoiceDeliveryPeriodEnd) + assert.Equal(t, "USD", detail.CurrencyCode) + assert.Equal(t, 358.35432, detail.ExchangeRate) + assert.Equal(t, "CARD", detail.PaymentMethod) + assert.Equal(t, "2023-08-20", detail.PaymentDate) + assert.Equal(t, "EDI", detail.InvoiceAppearance) +} + +func TestNewInvoiceDetail_SimplifiedInvoice(t *testing.T) { + invoice := &bill.Invoice{ + Currency: currency.USD, + ExchangeRates: []*currency.ExchangeRate{ + { + From: currency.USD, + To: currency.HUF, + Amount: num.AmountFromFloat64(358.35, 5), + }, + }, + IssueDate: *cal.NewDate(2023, 7, 15), + Tax: &bill.Tax{ + Tags: []cbc.Key{tax.TagSimplified}, + }, + } + + detail, err := NewInvoiceDetail(invoice) + assert.NoError(t, err) + assert.Equal(t, "SIMPLIFIED", detail.InvoiceCategory) +} + +func TestNewInvoiceDetail_NoExchangeRate(t *testing.T) { + invoice := &bill.Invoice{ + Currency: currency.JPY, + ExchangeRates: []*currency.ExchangeRate{ + { + From: currency.JPY, + To: currency.USD, + Amount: num.AmountFromFloat64(0.0068, 5), + }, + }, + IssueDate: *cal.NewDate(2023, 7, 15), + } + + _, err := NewInvoiceDetail(invoice) + assert.Error(t, err) + assert.Equal(t, ErrNoExchangeRate, err) +} diff --git a/head.go b/head.go index a8dcb6d..b9501cf 100644 --- a/head.go +++ b/head.go @@ -2,6 +2,7 @@ package nav import "github.com/invopop/gobl/bill" +// InvoiceHead contains data pertaining to the invoice as a whole. type InvoiceHead struct { SupplierInfo *SupplierInfo `xml:"supplierInfo"` CustomerInfo *CustomerInfo `xml:"customerInfo,omitempty"` @@ -16,12 +17,18 @@ func NewInvoiceHead(inv *bill.Invoice) (*InvoiceHead, error) { return nil, err } + customerInfo, err := NewCustomerInfo(inv.Customer) + if err != nil { + return nil, err + } + detail, err := NewInvoiceDetail(inv) if err != nil { return nil, err } return &InvoiceHead{ SupplierInfo: supplierInfo, + CustomerInfo: customerInfo, InvoiceDetail: detail, }, nil } diff --git a/invoice.go b/invoice.go index a9ded0f..10fc8ec 100644 --- a/invoice.go +++ b/invoice.go @@ -11,8 +11,8 @@ type InvoiceMain struct { type Invoice struct { //InvoiceReference InvoiceReference `xml:"invoiceReference,omitempty"` // Used for invoice modification - InvoiceHead *InvoiceHead `xml:"invoiceHead"` - //InvoiceLines InvoiceLines `xml:"invoiceLines,omitempty"` + InvoiceHead *InvoiceHead `xml:"invoiceHead"` + InvoiceLines *InvoiceLines `xml:"invoiceLines,omitempty"` //ProductFeeSummary ProductFeeSummary `xml:"productFeeSummary,omitempty"` InvoiceSummary *InvoiceSummary `xml:"invoiceSummary"` diff --git a/lines.go b/lines.go new file mode 100644 index 0000000..6d6f46b --- /dev/null +++ b/lines.go @@ -0,0 +1,37 @@ +package nav + +type InvoiceLines struct { + MergedItemIndicator bool `xml:"mergedItemIndicator"` // Indicates if the data report contains aggregated item data + Lines []Line `xml:"line"` +} + +type Line struct { + LineNumber int `xml:"lineNumber"` + LineModificationReference LineModificationReference `xml:"lineModificationReference,omitempty"` + ReferencesToOtherLines []ReferenceToOtherLine `xml:"referencesToOtherLines,omitempty"` // References to related items + AdvanceData AdvanceData `xml:"advanceData,omitempty"` // Data related to advanced payment + ProductCodes []ProductCode `xml:"productCodes,omitempty"` // Product codes + LineExpressionIndicator bool `xml:"lineExpressionIndicator"` // true if the quantity unit of the item can be expressed as a natural unit of measurement + LineNatureIndicator string `xml:"lineNatureIndicator,omitempty"` // Denotes sale of product or service + LineDescription string `xml:"lineDescription,omitempty"` + Quantity float64 `xml:"quantity,omitempty"` + UnitOfMeasure string `xml:"unitOfMeasure,omitempty"` + UnitOfMeasureOwn string `xml:"unitOfMeasureOwn,omitempty"` // Own quantity unit + UnitPrice float64 `xml:"unitPrice,omitempty"` + UnitPriceHUF float64 `xml:"unitPriceHUF,omitempty"` + LineDiscountData DiscountData `xml:"lineDiscountData,omitempty"` + LineAmountsNormal LineAmountsNormal `xml:"lineAmountsNormal,omitempty"` // For normal or aggregate invoices + LineAmountsSimplified LineAmountsSimplified `xml:"lineAmountsSimplified,omitempty"` // For simplified invoices + IntermediatedService bool `xml:"intermediatedService,omitempty"` // true if indirect service + //AggregateInvoiceLineData AggregateInvoiceLineData `xml:"aggregateInvoiceLineData,omitempty"` // Aggregate invoice data + //NewTransportMean NewTransportMean `xml:"newTransportMean,omitempty"` // Sale of new means of transport + //DepositIndicator bool `xml:"depositIndicator,omitempty"` // true if the item is a deposit + //ObligatedForProductFee bool `xml:"obligatedForProductFee,omitempty"` // true if a product fee obligation applies to the line item + //GPCExcise float64 `xml:",omitempty"` // Excise tax on natural gas + //DieselOilPurchase DieselOilPurchase `xml:"dieselOilPurchase,omitempty"` // Data on post-tax purchase diesel oil + //NetaDeclaration bool `xml:"netaDeclaration,omitempty"` // true if the tax liability determined by the Public Health Product Tax falls on the taxpayer + //ProductFeeClause ProductFeeClause `xml:"productFeeClause,omitempty"` // Clauses on environmental product charges + //LineProductFeeContent LineProductFeeContent `xml:"lineProductFeeContent,omitempty"` // Data on product fee content + //ConventionalLineInfo ConventionalLineInfo `xml:"conventionalLineInfo,omitempty"` // Other conventional named data + //AdditionalLineData AdditionalData `xml:"additionalLineData,omitempty"` // Additional data +} From a0d6a952c7803a8d3290b32513812322e46e8e03 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Fri, 16 Aug 2024 12:41:27 +0000 Subject: [PATCH 06/27] Including lines data --- lines.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 156 insertions(+), 17 deletions(-) diff --git a/lines.go b/lines.go index 6d6f46b..8bdfc45 100644 --- a/lines.go +++ b/lines.go @@ -1,28 +1,34 @@ package nav +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/org" +) + type InvoiceLines struct { MergedItemIndicator bool `xml:"mergedItemIndicator"` // Indicates if the data report contains aggregated item data Lines []Line `xml:"line"` } type Line struct { - LineNumber int `xml:"lineNumber"` - LineModificationReference LineModificationReference `xml:"lineModificationReference,omitempty"` - ReferencesToOtherLines []ReferenceToOtherLine `xml:"referencesToOtherLines,omitempty"` // References to related items - AdvanceData AdvanceData `xml:"advanceData,omitempty"` // Data related to advanced payment - ProductCodes []ProductCode `xml:"productCodes,omitempty"` // Product codes - LineExpressionIndicator bool `xml:"lineExpressionIndicator"` // true if the quantity unit of the item can be expressed as a natural unit of measurement - LineNatureIndicator string `xml:"lineNatureIndicator,omitempty"` // Denotes sale of product or service - LineDescription string `xml:"lineDescription,omitempty"` - Quantity float64 `xml:"quantity,omitempty"` - UnitOfMeasure string `xml:"unitOfMeasure,omitempty"` - UnitOfMeasureOwn string `xml:"unitOfMeasureOwn,omitempty"` // Own quantity unit - UnitPrice float64 `xml:"unitPrice,omitempty"` - UnitPriceHUF float64 `xml:"unitPriceHUF,omitempty"` - LineDiscountData DiscountData `xml:"lineDiscountData,omitempty"` - LineAmountsNormal LineAmountsNormal `xml:"lineAmountsNormal,omitempty"` // For normal or aggregate invoices - LineAmountsSimplified LineAmountsSimplified `xml:"lineAmountsSimplified,omitempty"` // For simplified invoices - IntermediatedService bool `xml:"intermediatedService,omitempty"` // true if indirect service + LineNumber int `xml:"lineNumber"` + //LineModificationReference LineModificationReference `xml:"lineModificationReference,omitempty"` + //ReferencesToOtherLines []ReferenceToOtherLine `xml:"referencesToOtherLines,omitempty"` // References to related items + //AdvanceData AdvanceData `xml:"advanceData,omitempty"` // Data related to advanced payment + ProductCodes *ProductCodes `xml:"productCodes,omitempty"` // Product codes + LineExpressionIndicator bool `xml:"lineExpressionIndicator"` // true if the quantity unit of the item can be expressed as a natural unit of measurement + LineNatureIndicator string `xml:"lineNatureIndicator,omitempty"` // Denotes sale of product or service + LineDescription string `xml:"lineDescription,omitempty"` + Quantity float64 `xml:"quantity,omitempty"` + UnitOfMeasure string `xml:"unitOfMeasure,omitempty"` + UnitOfMeasureOwn string `xml:"unitOfMeasureOwn,omitempty"` // Own quantity unit + UnitPrice float64 `xml:"unitPrice,omitempty"` + UnitPriceHUF float64 `xml:"unitPriceHUF,omitempty"` + //LineDiscountData DiscountData `xml:"lineDiscountData,omitempty"` + LineAmountsNormal *LineAmountsNormal `xml:"lineAmountsNormal,omitempty"` // For normal or aggregate invoices + LineAmountsSimplified *LineAmountsSimplified `xml:"lineAmountsSimplified,omitempty"` // For simplified invoices + //IntermediatedService bool `xml:"intermediatedService,omitempty"` // true if indirect service //AggregateInvoiceLineData AggregateInvoiceLineData `xml:"aggregateInvoiceLineData,omitempty"` // Aggregate invoice data //NewTransportMean NewTransportMean `xml:"newTransportMean,omitempty"` // Sale of new means of transport //DepositIndicator bool `xml:"depositIndicator,omitempty"` // true if the item is a deposit @@ -35,3 +41,136 @@ type Line struct { //ConventionalLineInfo ConventionalLineInfo `xml:"conventionalLineInfo,omitempty"` // Other conventional named data //AdditionalLineData AdditionalData `xml:"additionalLineData,omitempty"` // Additional data } + +type ProductCodes struct { + ProductCode []*ProductCode `xml:"productCode"` +} + +// One of code value or codeownvalue must be present +type ProductCode struct { + ProductCodeCategory string `xml:"productCodeCategory"` // Product code value for non-own product codes + ProductCodeValue string `xml:"productCodeValue,omitempty"` + ProductCodeOwnValue string `xml:"productCodeOwnValue,omitempty"` // Own product code value +} + +type LineAmountsNormal struct { + LineNetAmountData LineNetAmountData `xml:"lineNetAmountData"` + LineVatRate VatRate `xml:"lineVatRate"` + LineVatData LineVatData `xml:"lineVatData,omitempty"` + LineGrossAmountData LineGrossAmountData `xml:"lineGrossAmountData,omitempty"` +} + +type LineNetAmountData struct { + LineNetAmount float64 `xml:"lineNetAmount"` + LineNetAmountHUF float64 `xml:"lineNetAmountHUF"` +} + +type LineVatData struct { + LineVatAmount float64 `xml:"lineVatAmount"` + LineVatAmountHUF float64 `xml:"lineVatAmountHUF"` +} + +type LineGrossAmountData struct { + LineGrossAmount float64 `xml:"lineGrossAmount"` + LineGrossAmountHUF float64 `xml:"lineGrossAmountHUF"` +} + +type LineAmountsSimplified struct { + LineVatRate VatRate `xml:"lineVatRate"` + LineGrossAmountSimplified float64 `xml:"lineGrossAmountSimplified"` + LineGrossAmountSimplifiedHUF float64 `xml:"lineGrossAmountSimplifiedHUF"` +} + +var codeCategories = []string{ + "VTSZ", "SZJ", "KN", "AHK", "CSK", "KT", "EJ", "TESZOR", +} + +var validUnits = map[org.Unit]string{ + org.UnitPiece: "PIECE", org.UnitKilogram: "KILOGRAM", org.UnitMetricTon: "TON", org.UnitKilowattHour: "KWH", + org.UnitDay: "DAY", org.UnitHour: "HOUR", org.UnitMinute: "MINUTE", org.UnitMonth: "MONTH", + org.UnitLitre: "LITRE", org.UnitKilometre: "KILOMETER", org.UnitCubicMetre: "CUBIC_METER", + org.UnitMetre: "METER", org.UnitCarton: "CARTON", org.UnitPackage: "PACK"} + +func NewInvoiceLines(inv *bill.Invoice) *InvoiceLines { + + return &InvoiceLines{} +} + +func NewLine(line *bill.Line) *Line { + productCodes := &ProductCodes{} + if line.Item.Identities != nil { + productCodes = NewProductCodes(line.Item.Identities) + } + + lineExpression := false + lineUnit := "" + lineUnitOwn := "" + if line.Item.Unit != "" { + for unit, value := range validUnits { + if line.Item.Unit == unit { + lineExpression = true + lineUnit = value + break + } + } + if lineExpression == false { + lineUnitOwn = string(line.Item.Unit) + } + } + + lineNature := "" + if line.Item.Key != "" { + if line.Item.Key == "PRODUCT" { + lineNature = "PRODUCT" + } else if line.Item.Key == "SERVICE" { + lineNature = "SERVICE" + } else { + lineNature = "OTHER" + } + } + + return &Line{ + LineNumber: line.Index, + ProductCodes: productCodes, + LineExpressionIndicator: lineExpression, + LineNatureIndicator: lineNature, + LineDescription: line.Item.Name, + Quantity: line.Quantity.Float64(), + UnitOfMeasure: lineUnit, + UnitOfMeasureOwn: lineUnitOwn, + UnitPrice: line.Item.Price.Float64(), + } +} + +func NewProductCodes(identities []*org.Identity) *ProductCodes { + if len(identities) == 0 { + return nil + } + productCodes := &ProductCodes{} + for _, identity := range identities { + productCode := NewProductCode(identity) + productCodes.ProductCode = append(productCodes.ProductCode, productCode) + } + return productCodes +} + +func NewProductCode(identity *org.Identity) *ProductCode { + if identity.Type == "OWN" { + return &ProductCode{ + ProductCodeCategory: "OWN", + ProductCodeOwnValue: identity.Code.String(), + } + } + for _, category := range codeCategories { + if identity.Type == cbc.Code(category) { + return &ProductCode{ + ProductCodeCategory: category, + ProductCodeValue: identity.Code.String(), + } + } + } + return &ProductCode{ + ProductCodeCategory: "OTHER", + ProductCodeValue: identity.Code.String(), + } +} From d81315ab14810e45d6319ea4bed073dd570277cd Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Fri, 16 Aug 2024 16:54:11 +0000 Subject: [PATCH 07/27] Lines finished --- README.md | 3 +- lines.go | 87 ++++++++++++++++++++++------------ summary.go | 9 ---- vatrate.go | 135 +++++++++++++++++++++++++++++++++++++++++------------ 4 files changed, 163 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 8d8043c..31b196c 100644 --- a/README.md +++ b/README.md @@ -12,4 +12,5 @@ The invoice data content of the data report must be embedded, encoded in BASE64 - We don't support batch invoicing (It is used only for batch modifications) - We don't support modification of invoices - We don't support fiscal representatives -- We don't support aggregate invoices \ No newline at end of file +- We don't support aggregate invoices +- In the VAT rate we are missing the vat amount mismatch field \ No newline at end of file diff --git a/lines.go b/lines.go index 8bdfc45..41be2ab 100644 --- a/lines.go +++ b/lines.go @@ -3,7 +3,9 @@ package nav import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/num" "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" ) type InvoiceLines struct { @@ -55,7 +57,7 @@ type ProductCode struct { type LineAmountsNormal struct { LineNetAmountData LineNetAmountData `xml:"lineNetAmountData"` - LineVatRate VatRate `xml:"lineVatRate"` + LineVatRate *VatRate `xml:"lineVatRate"` LineVatData LineVatData `xml:"lineVatData,omitempty"` LineGrossAmountData LineGrossAmountData `xml:"lineGrossAmountData,omitempty"` } @@ -76,9 +78,9 @@ type LineGrossAmountData struct { } type LineAmountsSimplified struct { - LineVatRate VatRate `xml:"lineVatRate"` - LineGrossAmountSimplified float64 `xml:"lineGrossAmountSimplified"` - LineGrossAmountSimplifiedHUF float64 `xml:"lineGrossAmountSimplifiedHUF"` + LineVatRate *VatRate `xml:"lineVatRate"` + LineGrossAmountSimplified float64 `xml:"lineGrossAmountSimplified"` + LineGrossAmountSimplifiedHUF float64 `xml:"lineGrossAmountSimplifiedHUF"` } var codeCategories = []string{ @@ -91,55 +93,75 @@ var validUnits = map[org.Unit]string{ org.UnitLitre: "LITRE", org.UnitKilometre: "KILOMETER", org.UnitCubicMetre: "CUBIC_METER", org.UnitMetre: "METER", org.UnitCarton: "CARTON", org.UnitPackage: "PACK"} -func NewInvoiceLines(inv *bill.Invoice) *InvoiceLines { +func NewInvoiceLines(inv *bill.Invoice) (*InvoiceLines, error) { - return &InvoiceLines{} + invoiceLines := &InvoiceLines{} + taxinfo := newTaxInfo(inv) + rate, err := getInvoiceRate(inv) + if err != nil { + return nil, err + } + for _, line := range inv.Lines { + invoiceLine := NewLine(line, taxinfo, rate) + invoiceLines.Lines = append(invoiceLines.Lines, *invoiceLine) + } + invoiceLines.MergedItemIndicator = false + + return invoiceLines, nil } -func NewLine(line *bill.Line) *Line { - productCodes := &ProductCodes{} +func NewLine(line *bill.Line, info *taxInfo, rate float64) *Line { + lineNav := &Line{ + LineNumber: line.Index, + LineExpressionIndicator: false, + } + if line.Item.Identities != nil { - productCodes = NewProductCodes(line.Item.Identities) + lineNav.ProductCodes = NewProductCodes(line.Item.Identities) } - lineExpression := false - lineUnit := "" - lineUnitOwn := "" if line.Item.Unit != "" { for unit, value := range validUnits { if line.Item.Unit == unit { - lineExpression = true - lineUnit = value + lineNav.LineExpressionIndicator = true + lineNav.UnitOfMeasure = value break } } - if lineExpression == false { - lineUnitOwn = string(line.Item.Unit) + if !lineNav.LineExpressionIndicator { + lineNav.UnitOfMeasureOwn = string(line.Item.Unit) } } - lineNature := "" if line.Item.Key != "" { if line.Item.Key == "PRODUCT" { - lineNature = "PRODUCT" + lineNav.LineNatureIndicator = "PRODUCT" } else if line.Item.Key == "SERVICE" { - lineNature = "SERVICE" + lineNav.LineNatureIndicator = "SERVICE" } else { - lineNature = "OTHER" + lineNav.LineNatureIndicator = "OTHER" } } - return &Line{ - LineNumber: line.Index, - ProductCodes: productCodes, - LineExpressionIndicator: lineExpression, - LineNatureIndicator: lineNature, - LineDescription: line.Item.Name, - Quantity: line.Quantity.Float64(), - UnitOfMeasure: lineUnit, - UnitOfMeasureOwn: lineUnitOwn, - UnitPrice: line.Item.Price.Float64(), + vatCombo := line.Taxes.Get(tax.CategoryVAT) + if vatCombo != nil { + if info.simplifiedInvoice { + lineNav.LineAmountsSimplified = &LineAmountsSimplified{ + LineVatRate: NewVatRate(vatCombo, info), + LineGrossAmountSimplified: line.Total.Rescale(2).Float64(), + LineGrossAmountSimplifiedHUF: amountToHUF(line.Total, rate).Float64(), + } + } else { + lineNav.LineAmountsNormal = &LineAmountsNormal{ + LineNetAmountData: LineNetAmountData{ + LineNetAmount: line.Total.Rescale(2).Float64(), + LineNetAmountHUF: amountToHUF(line.Total, rate).Float64(), + }, + LineVatRate: NewVatRate(vatCombo, info), + } + } } + return lineNav } func NewProductCodes(identities []*org.Identity) *ProductCodes { @@ -174,3 +196,8 @@ func NewProductCode(identity *org.Identity) *ProductCode { ProductCodeValue: identity.Code.String(), } } + +func amountToHUF(amount num.Amount, ex float64) num.Amount { + result := amount.Multiply(num.AmountFromFloat64(ex, 5)) + return result.Rescale(2) +} diff --git a/summary.go b/summary.go index 7491610..9044f28 100644 --- a/summary.go +++ b/summary.go @@ -1,9 +1,5 @@ package nav -import ( - "github.com/invopop/gobl/num" -) - // Depends wether the invoice is simplified or not type InvoiceSummary struct { SummaryNormal *SummaryNormal `xml:"summaryNormal"` @@ -77,8 +73,3 @@ type VatRateVatData struct { }, nil }*/ - -func amountToHUF(amount num.Amount, ex float64) string { - result := amount.Multiply(num.AmountFromFloat64(ex, 5)) - return result.Rescale(2).String() -} diff --git a/vatrate.go b/vatrate.go index a128d27..272c710 100644 --- a/vatrate.go +++ b/vatrate.go @@ -1,11 +1,17 @@ package nav +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/regimes/hu" + "github.com/invopop/gobl/tax" +) + //"github.com/invopop/gobl/regimes/hu" // Vat Rate may contain exactly one of the 8 possible fields type VatRate struct { - VatPercentage float64 `xml:"vatPercentage,omitempty"` - //VatContent float64 `xml:"vatContent"` //VatContent is only for simplified invoices + VatPercentage float64 `xml:"vatPercentage,omitempty"` + VatContent float64 `xml:"vatContent,omitempty"` //VatContent is only for simplified invoices VatExemption *DetailedReason `xml:"vatExemption,omitempty"` VatOutOfScope *DetailedReason `xml:"vatOutOfScope,omitempty"` VatDomesticReverseCharge bool `xml:"vatDomesticReverseCharge,omitempty"` @@ -25,8 +31,7 @@ type VatAmountMismatch struct { } type taxInfo struct { - //simplifiedRegime bool - outOfScope bool + simplifiedInvoice bool domesticReverseCharge bool travelAgency bool secondHand bool @@ -34,37 +39,105 @@ type taxInfo struct { antique bool } +func NewVatRate(obj any, info *taxInfo) *VatRate { + switch obj := obj.(type) { + case *tax.RateTotal: + return newVatRateTotal(obj, info) + case *tax.Combo: + return newVatRateCombo(obj, info) + } + return nil +} + // NewVatRate creates a new VatRate from a taxid -/*func NewVatRate(rate *tax.RateTotal, info *taxInfo) *VatRate { - if rate.Key != tax.RateExempt && rate.Key != tax.RateZero { - return &VatRate{VatPercentage: rate.Percent.Amount().Float64()} - } - if rate.Key == tax.RateExempt { - if info.outOfScope { - // Q: Is there a way in GOBL to access the extension names? - // This could maybe be done accessing the regime and there the extensions. We can use the name as the reason. - return &VatRate{VatOutOfScope: &DetailedReason{Case: rate.Ext[hu.ExtKeyExemptionCode].String(), Reason: "Out of Scope"}} - } - if info.domesticReverseCharge { - return &VatRate{VatDomesticReverseCharge: true} - } - if info.travelAgency { - return &VatRate{MarginSchemeIndicator: "TRAVEL_AGENCY"} +func newVatRateTotal(rate *tax.RateTotal, info *taxInfo) *VatRate { + // First if it is not exent or simplified invoice we can return the percentage + if rate.Percent != nil { + return &VatRate{VatPercentage: rate.Percent.Amount().Rescale(4).Float64()} + } + + // If it is a simplified invoice we can return the content + if info.simplifiedInvoice { + return &VatRate{VatContent: rate.Amount.Rescale(4).Float64()} + } + + // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode + for k, v := range rate.Ext { + if k == hu.ExtKeyExemptionCode { + return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}} } - if info.secondHand { - return &VatRate{MarginSchemeIndicator: "SECOND_HAND"} + + if k == hu.ExtKeyVatOutOfScopeCode { + return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}} } - if info.art { - return &VatRate{MarginSchemeIndicator: "ARTWORK"} + } + + // Check if it is a domestic reverse charge + if info.domesticReverseCharge { + return &VatRate{VatDomesticReverseCharge: true} + } + + // Check the margin scheme indicators + + if info.travelAgency { + return &VatRate{MarginSchemeIndicator: "TRAVEL_AGENCY"} + } + if info.secondHand { + return &VatRate{MarginSchemeIndicator: "SECOND_HAND"} + } + if info.art { + return &VatRate{MarginSchemeIndicator: "ARTWORK"} + } + if info.antique { + return &VatRate{MarginSchemeIndicator: "ANTIQUE"} + } + + // Missing vat amount mismatch + + return &VatRate{NoVatCharge: true} + +} + +func newVatRateCombo(c *tax.Combo, info *taxInfo) *VatRate { + // First if it is not exent or simplified invoice we can return the percentage + if c.Percent != nil { + return &VatRate{VatPercentage: c.Percent.Amount().Rescale(4).Float64()} + } + + // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode + for k, v := range c.Ext { + if k == hu.ExtKeyExemptionCode { + return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}} } - if info.antique { - return &VatRate{MarginSchemeIndicator: "ANTIQUE"} + + if k == hu.ExtKeyVatOutOfScopeCode { + return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}} } - return &VatRate{VatExemption: &DetailedReason{Case: rate.Ext[hu.ExtKeyExemptionCode].String(), Reason: "Exempt"}} } - return &VatRate{VatPercentage: rate.Percent.Amount().Float64()} - //TODO: Missing the last 2 cases (VatAmountMismatch and NoVatCharge) + // Check if it is a domestic reverse charge + if info.domesticReverseCharge { + return &VatRate{VatDomesticReverseCharge: true} + } + + // Check the margin scheme indicators + + if info.travelAgency { + return &VatRate{MarginSchemeIndicator: "TRAVEL_AGENCY"} + } + if info.secondHand { + return &VatRate{MarginSchemeIndicator: "SECOND_HAND"} + } + if info.art { + return &VatRate{MarginSchemeIndicator: "ARTWORK"} + } + if info.antique { + return &VatRate{MarginSchemeIndicator: "ANTIQUE"} + } + + // Missing vat amount mismatch + + return &VatRate{NoVatCharge: true} } // Until PR approved in regimes this wont work @@ -73,8 +146,8 @@ func newTaxInfo(inv *bill.Invoice) *taxInfo { if inv.Tax != nil { for _, scheme := range inv.Tax.Tags { switch scheme { - case hu.TagOutOfScope: - info.outOfScope = true + case tax.TagSimplified: + info.simplifiedInvoice = true case hu.TagDomesticReverseCharge: info.domesticReverseCharge = true case hu.TagTravelAgency: @@ -89,4 +162,4 @@ func newTaxInfo(inv *bill.Invoice) *taxInfo { } } return info -}*/ +} From 24cf7440760afdcdabb66f2314ab326414515e82 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Mon, 19 Aug 2024 10:50:17 +0000 Subject: [PATCH 08/27] Adding schemas --- README.md | 6 +- lines.go | 17 +- nav.go | 1 + schemas/common.xsd | 727 ++++++++++++ schemas/invoiceBase.xsd | 329 ++++++ schemas/invoiceData.xsd | 2368 +++++++++++++++++++++++++++++++++++++++ vatrate.go | 76 +- 7 files changed, 3480 insertions(+), 44 deletions(-) create mode 100644 schemas/common.xsd create mode 100644 schemas/invoiceBase.xsd create mode 100644 schemas/invoiceData.xsd diff --git a/README.md b/README.md index 31b196c..d33b5e4 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,10 @@ Convert GOBL into Hungarian NAV XML documents The invoice data content of the data report must be embedded, encoded in BASE64 format, in the ManageInvoiceRequest/invoiceoperations/invoiceOperation/InvoiceData element. -## Things to include in validation: -- If the 9th digit of the tax id is 5, the group member tax id must exist and should be 4. If the Vat Status is Domestic (Hungarian) always a vat id -- The 9th digit of the Vat Ids must be 1,2,3 or 5 and of the member groups must be 4. - ## Limitations - We don't support batch invoicing (It is used only for batch modifications) - We don't support modification of invoices - We don't support fiscal representatives - We don't support aggregate invoices -- In the VAT rate we are missing the vat amount mismatch field \ No newline at end of file +- In the VAT rate we are missing the vat amount mismatch field (used when VAT has been charged under section 11 or 14) \ No newline at end of file diff --git a/lines.go b/lines.go index 41be2ab..afa02b5 100644 --- a/lines.go +++ b/lines.go @@ -102,7 +102,10 @@ func NewInvoiceLines(inv *bill.Invoice) (*InvoiceLines, error) { return nil, err } for _, line := range inv.Lines { - invoiceLine := NewLine(line, taxinfo, rate) + invoiceLine, err := NewLine(line, taxinfo, rate) + if err != nil { + return nil, err + } invoiceLines.Lines = append(invoiceLines.Lines, *invoiceLine) } invoiceLines.MergedItemIndicator = false @@ -110,7 +113,7 @@ func NewInvoiceLines(inv *bill.Invoice) (*InvoiceLines, error) { return invoiceLines, nil } -func NewLine(line *bill.Line, info *taxInfo, rate float64) *Line { +func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { lineNav := &Line{ LineNumber: line.Index, LineExpressionIndicator: false, @@ -145,9 +148,13 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) *Line { vatCombo := line.Taxes.Get(tax.CategoryVAT) if vatCombo != nil { + vatRate, err := NewVatRate(vatCombo, info) + if err != nil { + return nil, err + } if info.simplifiedInvoice { lineNav.LineAmountsSimplified = &LineAmountsSimplified{ - LineVatRate: NewVatRate(vatCombo, info), + LineVatRate: vatRate, LineGrossAmountSimplified: line.Total.Rescale(2).Float64(), LineGrossAmountSimplifiedHUF: amountToHUF(line.Total, rate).Float64(), } @@ -157,11 +164,11 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) *Line { LineNetAmount: line.Total.Rescale(2).Float64(), LineNetAmountHUF: amountToHUF(line.Total, rate).Float64(), }, - LineVatRate: NewVatRate(vatCombo, info), + LineVatRate: vatRate, } } } - return lineNav + return lineNav, nil } func NewProductCodes(identities []*org.Identity) *ProductCodes { diff --git a/nav.go b/nav.go index 2ab6328..9fbfb95 100644 --- a/nav.go +++ b/nav.go @@ -16,6 +16,7 @@ var ( ErrNotHungarian = newValidationError("only hungarian invoices are supported") ErrNoExchangeRate = newValidationError("no exchange rate to HUF found") ErrInvalidGroupMemberCode = newValidationError("invalid group member code") + ErrNoVatRateField = newValidationError("no vat rate field found") ) // ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed diff --git a/schemas/common.xsd b/schemas/common.xsd new file mode 100644 index 0000000..c28bca7 --- /dev/null +++ b/schemas/common.xsd @@ -0,0 +1,727 @@ + + + + + + + Atomi string típus 100 hosszra + Atomic string type for 100 length + + + + + + + + + Atomi string típus 1024 hosszra + Atomic string type for 1024 length + + + + + + + + + Atomi string típus 128 hosszra + Atomic string type for 128 length + + + + + + + + + Atomi string típus 15 hosszra + Atomic string type for 15 length + + + + + + + + + Atomi string típus 16 hosszra + Atomic string type for 16 length + + + + + + + + + Atomi string típus 2 hosszra + Atomic string type for 2 length + + + + + + + + + Atomi string típus 200 hosszra + Atomic string type for 200 length + + + + + + + + + Atomi string típus 2048 hosszra + Atomic string type for 2048 length + + + + + + + + + Atomi string típus 255 hosszra + Atomic string type for 255 length + + + + + + + + + Atomi string típus 256 hosszra + Atomic string type for 256 length + + + + + + + + + Atomi string típus 32 hosszra + Atomic string type for 32 length + + + + + + + + + Atomi string típus 4 hosszra + Atomic string type for 4 length + + + + + + + + + Atomi string típus 4000 hosszra + Atomic string type for 4000 length + + + + + + + + + Atomi string típus 50 hosszra + Atomic string type for 50 length + + + + + + + + + Atomi string típus 512 hosszra + Atomic string type for 512 length + + + + + + + + + Atomi string típus 64 hosszra + Atomic string type for 64 length + + + + + + + + + Atomi string típus 8 hosszra + Atomic string type for 8 length + + + + + + + + + Általános UTC dátum + Generic UTC date + + + + + + + + Általános lebegőpontos érték + Generic float point value + + + + + + Általános UTC időbélyeg + Generic UTC timestamp + + + + + + + + SHA256 kód megadására szolgáló típus + Field type for holding an SHA256 code + + + + + + + + SHA512 kód megadására szolgáló típus + Field type for holding an SHA512 code + + + + + + + + Legfeljebb 100 karaktert tartalmazó szöveg típus + String of maximum 100 characters + + + + + + + + Legfeljebb 1024 karaktert tartalmazó szöveg típus + String of maximum 1024 characters + + + + + + + + Legfeljebb 15 karaktert tartalmazó szöveg típus + String of maximum 15 characters + + + + + + + + Legfeljebb 200 karaktert tartalmazó szöveg típus + String of maximum 200 characters + + + + + + + + Legfeljebb 255 karaktert tartalmazó szöveg típus + String of maximum 255 characters + + + + + + + + Legfeljebb 50 karaktert tartalmazó szöveg típus + String of maximum 50 characters + + + + + + + + Legfeljebb 512 karaktert tartalmazó szöveg típus + String of maximum 512 characters + + + + + + + + + Bankszámla megadására szolgáló típus + Type of bank account number + + + + + + + + + + Közösségi adószám típus + Community VAT registration number + + + + + + + + + + Országkód típus ISO 3166 alpha-2 szabvány szerint + Country code type (ISO 3166 alpha-2) + + + + + + + + + Megyekód + County code + + + + + + + + + Pénznem típus (ISO 4217 szabvány szerinti 3 hosszú pénznem kód) + Currency type (three digit ISO 4217 currency code) + + + + + + + + + Kereskedelmi gépjármű forgalmi rendszáma (csak betűk és számok) + Registration number of commercial motor vehicle (letters and numbers only) + + + + + + + + + + Irányítószám típus + ZIP code type + + + + + + + + + + Az adószám nyolc jegyű törzsszám része + The 8-digit core number section of the tax number + + + + + + + + + ÁFA kód + VAT code + + + + + + + + + + Üzleti eredmény kód típus + Business result code type + + + + + Hiba + Error + + + + + Figyelmeztetés + Warn + + + + + Tájékoztatás + Information + + + + + + + Generált azonosító típus + Generated ID type + + + + + + + + + + Funkciókód típus + Function code type + + + + + Sikeres művelet + Successful operation + + + + + Hiba + Error + + + + + + + Felhasználónév típus + Login type + + + + + + + + + + Lapozó paraméter típus kérések számára + Page parameter type for requests + + + + + + + + Lapozó paraméter típus válaszok számára + Page parameter type for responses + + + + + + + + Technikai eredmény kód típus + Technical result code type + + + + + Kritikus hiba + Critical error + + + + + Hiba + Error + + + + + + + A kérés tranzakcionális adatai + Transactional data of the request + + + + + A kérés/válasz azonosítója, minden üzenetváltásnál - adószámonként - egyedi + Identifier of the request/response, unique with the taxnumber in every data exchange transaction + + + + + A kérés/válasz keletkezésének UTC ideje + UTC time of the request/response + + + + + A kérés/válasz verziószáma, hogy a hívó melyik interfész verzió szerint küld adatot és várja a választ + Request version number, indicating which datastructure the client sends data in, and in which the response is expected + + + + + A header verziószáma + Header version number + + + + + + + Alap kérés adatok + Basic request data + + + + + A kérés tranzakcionális adatai + Transactional data of the request + + + + + A kérés authentikációs adatai + Authentication data of the request + + + + + + + Alap válasz adatok + Basic response data + + + + + A válasz tranzakcionális adatai + Transactional data of the response + + + + + Alap válaszeredmény adatok + Basic result data + + + + + + + Alap válaszeredmény adatok + Basic result data + + + + + Feldolgozási eredmény + Processing result + + + + + A feldolgozási hibakód + Processing error code + + + + + Feldolgozási üzenet + Processing message + + + + + Egyéb értesítések + Miscellaneous notifications + + + + + + + Kriptográfiai metódust leíró típus + Denoting type of cryptographic method + + + + + + + + + + Általános hibatípus minden REST operációra + Generic fault type for every REST operation + + + + + + + + Egyéb értesítések + Miscellaneous notifications + + + + + Értesítés + Notification + + + + + + + Értesítés + Notification + + + + + Értesítés kód + Notification code + + + + + Értesítés szöveg + Notification text + + + + + + + Technikai validációs választípus + Technical validation response type + + + + + Validációs eredmény + Validation result + + + + + Validációs hibakód + Validation error code + + + + + Feldolgozási üzenet + Processing message + + + + + + + A kérés authentikációs adatai + Authentication data of the request + + + + + A technikai felhasználó login neve + Login name of the technical user + + + + + A technikai felhasználó jelszavának hash értéke + Hash value of the technical user's password + + + + + A rendszerben regisztrált adózó adószáma, aki nevében a technikai felhasználó tevékenykedik + The taxpayer's tax number, whose name the technical user operates in + + + + + A kérés aláírásának hash értéke + Hash value of the request's signature + + + + + + + + Az összes REST operációra vonatkozó hibaválasz generikus elementje + General error response of every REST operation + + + + + + + + + + Az összes REST operációra vonatkozó kivétel válasz generikus elementje + General exception response of every REST operation + + + + + + + + diff --git a/schemas/invoiceBase.xsd b/schemas/invoiceBase.xsd new file mode 100644 index 0000000..00ae80f --- /dev/null +++ b/schemas/invoiceBase.xsd @@ -0,0 +1,329 @@ + + + + + + + Számla megjelenési formája típus + Form of appearance of the invoice type + + + + + Papír alapú számla + Invoice issued on paper + + + + + Elektronikus formában előállított, nem EDI számla + Electronic invoice (non-EDI) + + + + + EDI számla + EDI invoice + + + + + A szoftver nem képes azonosítani vagy a kiállításkor nem ismert + The software cannot be identify the form of appearance of the invoice or it is unknown at the time of issue + + + + + + + A számla típusa + Type of invoice + + + + + Normál (nem egyszerűsített és nem gyűjtő) számla + Normal (not simplified and not aggregate) invoice + + + + + Egyszerűsített számla + Simplified invoice + + + + + Gyűjtőszámla + Aggregate invoice + + + + + + + Dátum típus az Online Számla rendszerben + Date type in the Online Invoice system + + + + + + + + + Sorszám típus az Online Számla rendszerben + Index type in the Online Invoice system + + + + + + + + + Időbélyeg típus az Online Számla rendszerben + Timestamp type in the Online Invoice system + + + + + + + + + Sorszám típus kötegelt számlaművelethez az Online Számla rendszerben + Index type for batch invoice operation in the Online Invoice system + + + + + + + + Tételszám típus + Line number type + + + + + + + + + Pénzérték típus. Maximum 18 számjegy, ami 2 tizedesjegyet tartalmazhat + Field type for money value input. Maximum 18 digits that may include 2 decimal places + + + + + + + + + Fizetés módja + Method of payment + + + + + Banki átutalás + Bank transfer + + + + + Készpénz + Cash + + + + + Bankkártya, hitelkártya, egyéb készpénz helyettesítő eszköz + Debit card, credit card, other cash-substitute payment instrument + + + + + Utalvány, váltó, egyéb pénzhelyettesítő eszköz + Voucher, bill of exchange, other non-cash payment instrument + + + + + Egyéb + Other + + + + + + + Cím típus, amely vagy egyszerű, vagy részletes címet tartalmaz + Format of address that includes either a simple or a detailed address + + + + + Egyszerű cím + Simple address + + + + + Részletes cím + Detailed address + + + + + + + Részletes címadatok + Detailed address data + + + + + Az országkód ISO 3166 alpha-2 szabvány szerint + ISO 3166 alpha-2 country code + + + + + Tartomány kódja (amennyiben értelmezhető az adott országban) az ISO 3166-2 alpha 2 szabvány szerint + ISO 3166 alpha-2 province code (if appropriate in a given country) + + + + + Irányítószám (amennyiben nem értelmezhető, 0000 értékkel kell kitölteni) + ZIP code (If can not be interpreted, need to be filled "0000") + + + + + Település + Settlement + + + + + Közterület neve + Name of public place + + + + + Közterület jellege + Category of public place + + + + + Házszám + House number + + + + + Épület + Building + + + + + Lépcsőház + Staircase + + + + + Emelet + Floor + + + + + Ajtó + Door number + + + + + Helyrajzi szám + Lot number + + + + + + + Egyszerű címtípus + Simple address type + + + + + Az országkód az ISO 3166 alpha-2 szabvány szerint + ISO 3166 alpha-2 country code + + + + + Tartomány kódja (amennyiben értelmezhető az adott országban) az ISO 3166-2 alpha 2 szabvány szerint + ISO 3166 alpha-2 province code (if appropriate in a given country) + + + + + Irányítószám (amennyiben nem értelmezhető, 0000 értékkel kell kitölteni) + ZIP code (If can not be interpreted, need to be filled "0000") + + + + + Település + Settlement + + + + + További címadatok (pl. közterület neve és jellege, házszám, emelet, ajtó, helyrajzi szám, stb.) + Further address data (name and type of public place, house number, floor, door, lot number, etc.) + + + + + + + Adószám típus + Tax number type + + + + + Az adóalany adó törzsszáma. Csoportos adóalany esetén csoportazonosító szám + Core tax number of the taxable person. In case of group taxation arrangement the group identification number + + + + + ÁFA kód az adóalanyiság típusának jelzésére. Egy számjegy + VAT code to indicate taxation type of the taxpayer. One digit + + + + + Megyekód, két számjegy + County code, two digits + + + + + diff --git a/schemas/invoiceData.xsd b/schemas/invoiceData.xsd new file mode 100644 index 0000000..3e6d047 --- /dev/null +++ b/schemas/invoiceData.xsd @@ -0,0 +1,2368 @@ + + + + + + + + Vevő ÁFA szerinti státusz típusa + Customers status type by VAT + + + + + Belföldi ÁFA alany + Domestic VAT subject + + + + + Egyéb (belföldi nem ÁFA alany, nem természetes személy, külföldi ÁFA alany és külföldi nem ÁFA alany, nem természetes személy) + Other (domestic non-VAT subject, non-natural person, foreign VAT subject and foreign non-VAT subject, non-natural person) + + + + + Nem ÁFA alany (belföldi vagy külföldi) természetes személy + Non-VAT subject (domestic or foreign) natural person + + + + + + + Az adatmező egyedi azonosító típusa + Unique identification type of the data field + + + + + + + + + + EKÁER szám azonosító típus + EKÁER number identifier type + + + + + + + + Árfolyam adat típus + Exchange rate data type + + + + + + + + + + Adott tételsor termékértékesítés vagy szolgáltatás nyújtás jellegének jelzése + Indication of the nature of the supply of goods or services on a given line + + + + + Termékértékesítés + Supply of goods + + + + + Szolgáltatás nyújtás + Supply of services + + + + + Egyéb, nem besorolható + Other, non-qualifiable + + + + + + + A számlatétel módosítás típusa + Invoice line modification type + + + + + + + + + Különbözet szerinti szabályozás típus + Field type for inputting margin-scheme taxation + + + + + Utazási irodák + Travel agencies + + + + + Használt cikkek + Second-hand goods + + + + + Műalkotások + Works of art + + + + + Gyűjteménydarabok és régiségek + Collector’s items and antiques + + + + + + + A termékkód fajtájának jelölésére szolgáló típus + The type used to mark the kind of product code + + + + + + + Vámtarifa szám VTSZ + Customs tariff number CTN + + + + + Szolgáltatás jegyzék szám SZJ + Business service list number BSL + + + + + KN kód (Kombinált Nómenklatúra, 2658/87/EGK rendelet I. melléklete) + CN code (Combined Nomenclature, 2658/87/ECC Annex I) + + + + + A Jövedéki törvény (2016. évi LXVIII. tv) szerinti e-TKO adminisztratív hivatkozási kódja AHK + Administrative reference code of e-TKO defined in the Act LXVIII of 2016 on Excise Duties + + + + + A termék 343/2011. (XII. 29) Korm. rendelet 1. sz. melléklet A) cím szerinti csomagolószer-katalógus kódja (CsK kód) + Packaging product catalogue code of the product according to the Title A) in the Schedule No.1 of the Government Decree No. 343/2011. (XII. 29.) + + + + + A termék 343/2011. (XII. 29) Korm. rendelet 1. sz. melléklet B) cím szerinti környezetvédelmi termékkódja (Kt kód) + Environmental protection product code of the product according to the Title B) in the Schedule No.1 of the Government Decree No. 343/2011. (XII. 29.) + + + + + Építményjegyzék szám + Classification of Inventory of Construction + + + + + A Termékek és Szolgáltatások Osztályozási Rendszere (TESZOR) szerinti termékkód - 451/2008/EK rendelet + Product code according to the TESZOR (Hungarian Classification of Goods and Services), Classification of Product by Activity, CPA - regulation 451/2008/EC + + + + + A vállalkozás által képzett termékkód + The own product code of the enterprise + + + + + Egyéb termékkód + Other product code + + + + + + + Termékkód típus + Field type for inputting product codes + + + + + + + + + + Díjtétel egység típus + Unit of the rate type + + + + + Darab + Piece + + + + + Kilogramm + Kilogram + + + + + + + Termékdíj összesítés típus + Product fee summary type + + + + + Visszaigénylés + Refund + + + + + Raktárba szállítás + Deposit + + + + + + + Termékáram típus + Product stream + + + + + Akkumulátor + Battery + + + + + Csomagolószer + Packaging products + + + + + Egyéb kőolajtermék + Other petroleum product + + + + + Az elektromos, elektronikai berendezés + The electric appliance, electronic equipment + + + + + Gumiabroncs + Tire + + + + + Reklámhordozó papír + Commercial printing paper + + + + + Az egyéb műanyag termék + Other plastic product + + + + + Egyéb vegyipari termék + Other chemical product + + + + + Irodai papír + Paper stationery + + + + + + + Mennyiségi érték típus. Maximum 22 számjegy, ami 10 tizedesjegyet tartalmazhat + Field type for quantity values. Maximum 22 digits that may include 10 decimal places + + + + + + + + + Arány megadására szolgáló típus. 0 és 1 közötti szám, legfeljebb 4 tizedesjegy pontossággal + Rate type. A number between 0 and 1, may include maximum 4 decimal places + + + + + + + + + + + Az átvállalás adatait tartalmazó típus + Field type for data of takeover + + + + + A 2011. évi LXXXV. tv. 14. § (4) bekezdés szerint az eladó (első belföldi forgalomba hozó) vállalja át a vevő termékdíj-kötelezettségét + The supplier takes over buyer’s product fee liability on the basis of Paragraph (4), Section 14 of the Act LXXXV of 2011 + + + + + A 2011. évi LXXXV. tv. 14. § (5) aa) alpontja szerint a vevő szerződés alapján átvállalja az eladó termékdíj-kötelezettségét + On the basis of contract, the buyer takes over supplier’s product fee liability on the basis of sub-point aa), Paragraph (5), Section 14 of the Act LXXXV of 2011 + + + + + + + + + + + + + + + + + Mennyiség egység típus + Unit of measure type + + + + + Darab + Piece + + + + + Kilogramm + Kilogram + + + + + Metrikus tonna + Metric ton + + + + + Kilowatt óra + Kilowatt hour + + + + + Nap + Day + + + + + Óra + Hour + + + + + Perc + Minute + + + + + Hónap + Month + + + + + Liter + Liter + + + + + Kilométer + Kilometer + + + + + Köbméter + Cubic meter + + + + + Méter + Meter + + + + + Folyóméter + Linear meter + + + + + Karton + Carton + + + + + Csomag + Pack + + + + + Saját mennyiségi egység megnevezés + Own unit of measure + + + + + + + További adat leírására szolgáló típus + Type for additional data description + + + + + Az adatmező egyedi azonosítója + Unique identification of the data field + + + + + Az adatmező tartalmának szöveges leírása + Description of content of the data field + + + + + Az adat értéke + Value of the data + + + + + + + Előleghez kapcsolódó adatok + Advance related data + + + + + Értéke true, ha a számla tétel előleg jellegű + The value is true if the invoice item is a kind of advance charge + + + + + Előleg fizetéshez kapcsolódó adatok + Advance payment related data + + + + + + + Előlegfizetéshez kapcsolódó adatok + Advance payment related data + + + + + Az előlegszámlának a sorszáma, amelyben az előlegfizetés történt + Invoice number containing the advance payment + + + + + Az előleg fizetésének dátuma + Payment date of the advance + + + + + Az előlegfizetés során alkalmazott árfolyam + Applied exchange rate of the advance + + + + + + + A gyűjtő számlára vonatkozó speciális adatokat tartalmazó típus + Field type including aggregate invoice special data + + + + + A tételhez tartozó árfolyam, 1 (egy) egységre vonatkoztatva. Csak külföldi pénznemben kiállított gyűjtő számla esetén kitöltendő + The exchange rate applied to the item, pertaining to 1 (one) unit. This should be filled in only if an aggregate invoice in foreign currency is issued + + + + + Gyűjtőszámla esetén az adott tétel teljesítési dátuma + Delivery date of the given item in the case of an aggregate invoice + + + + + + + Légi közlekedési eszköz + Aircraft + + + + + Felszállási tömeg kilogrammban + Take-off weight in kilogram + + + + + Értéke true ha a jármű az ÁFA tv. 259.§ 25. c) szerinti kivétel alá tartozik + The value is true if the means of transport is exempt from VAT as per section 259 [25] (c) + + + + + Repült órák száma + Number of aviated hours + + + + + + + Kötegelt módosító okirat adatai + Data of a batch of modification documents + + + + + A módosító okirat sorszáma a kötegen belül + Sequence number of the modification document within the batch + + + + + Egy számla vagy módosító okirat adatai + Data of a single invoice or modification document + + + + + + + Szerződésszámok + Contract numbers + + + + + Szerződésszám + Contract number + + + + + + + A számlafeldolgozást segítő, egyezményesen nevesített egyéb adatok + Other conventionally named data to assist in invoice processing + + + + + Megrendelésszám(ok) + Order numbers + + + + + Szállítólevél szám(ok) + Delivery notes + + + + + Szállítási dátum(ok) + Shipping dates + + + + + Szerződésszám(ok) + Contract numbers + + + + + Az eladó vállalati kódja(i) + Company codes of the supplier + + + + + A vevő vállalati kódja(i) + Company codes of the customer + + + + + Beszállító kód(ok) + Dealer codes + + + + + Költséghely(ek) + Cost centers + + + + + Projektszám(ok) + Project numbers + + + + + Főkönyvi számlaszám(ok) + General ledger account numbers + + + + + Kiállítói globális helyazonosító szám(ok) + Supplier's global location numbers + + + + + Vevői globális helyazonosító szám(ok) + Customer's global location numbers + + + + + Anyagszám(ok) + Material numbers + + + + + Cikkszám(ok) + Item number(s) + + + + + EKÁER azonosító(k) + EKAER ID-s + + + + + + + Költséghelyek + Cost centers + + + + + Költséghely + Cost center + + + + + + + A vevő vállalati kódjai + Company codes of the customer + + + + + A vevő vállalati kódja + Company code of the customer + + + + + + + Ha az eladó a vevő nyilatkozata alapján mentesül a termékdíj megfizetése alól, akkor az érintett termékáram + Should the supplier, based on statement given by the purchaser, be exempted from paying product fee, then the product stream affected + + + + + Termékáram + Product stream + + + + + Termékdíj köteles termék tömege kilogrammban + Weight of product fee obliged product in kilogram + + + + + + + A vevő adatai + Customer data + + + + + Vevő ÁFA szerinti státusza + Customers status by VAT + + + + + A vevő ÁFA alanyisági adatai + VAT subjectivity data of the customer + + + + + A vevő neve + Name of the customer + + + + + A vevő címe + Address of the customer + + + + + Vevő bankszámlaszáma + Bank account number of the customer + + + + + + + Adószám, amely alatt a számlán szereplő termékbeszerzés vagy szolgáltatás igénybevétele történt. Lehet csoportazonosító szám is + Tax number or group identification number, under which the purchase of goods or services is done + + + + + + + Csoport tag adószáma, ha a termékbeszerzés vagy szolgáltatás igénybevétele csoportazonosító szám alatt történt + Tax number of group member, when the purchase of goods or services is done under group identification number + + + + + + + + + A vevő ÁFA alanyisági adatai + VAT subjectivity data of the customer + + + + + Belföldi adószám, amely alatt a számlán szereplő termékbeszerzés vagy szolgáltatás igénybevétele történt. Lehet csoportazonosító szám is + Domestic tax number or group identification number, under which the purchase of goods or services is done + + + + + Közösségi adószám + Community tax number + + + + + Harmadik országbeli adóazonosító + Third state tax identification number + + + + + + + Beszállító kódok + Dealer codes + + + + + Beszállító kód + Dealer code + + + + + + + Szállítólevél számok + Delivery notes + + + + + Szállítólevél szám + Delivery note + + + + + + + Mentesség, kivétel részletes indokolása + Detailed justification of exemption + + + + + Az eset leírása kóddal + Case notation with code + + + + + Az eset leírása szöveggel + Case notation with text + + + + + + + Gázolaj adózottan történő beszerzésének adatai – 45/2016 (XI. 29.) NGM rendelet 75. § (1) a) + Data of gas oil acquisition after taxation – point a), paragraph (1) of Section 75 of the NGM Decree No. 45/2016. (XI. 29.) + + + + + Gázolaj beszerzés helye + Place of purchase of the gas oil + + + + + Gázolaj beszerzés dátuma + Date of purchase of gas oil + + + + + Kereskedelmi jármű forgalmi rendszáma (csak betűk és számok) + Registration number of vehicle (letters and numbers only) + + + + + Gépi bérmunka-szolgáltatás során felhasznált gázolaj mennyisége literben – Jöt. 117. § (2) + Fordítandó helyett: Quantity of diesel oil used for contract work and machinery hire service in liter – Act LXVIII of 2016 on Excise Tax section 117 (2) + + + + + + + Árengedmény adatok + Discount data + + + + + Az árengedmény leírása + Description of the discount + + + + + Tételhez tartozó árengedmény összege a számla pénznemében, ha az egységár nem tartalmazza + Total amount of discount per item expressed in the currency of the invoice if not included in the unit price + + + + + Tételhez tartozó árengedmény aránya, ha az egységár nem tartalmazza + Rate of discount per item expressed in the currency of the invoice if not included in the unit price + + + + + + + EKÁER azonosító(k) + EKAER ID-s + + + + + EKÁER azonosító + EKAER numbers; EKAER stands for Electronic Trade and Transport Control System + + + + + + + A pénzügyi képviselő adatai + Fiscal representative data + + + + + A pénzügyi képviselő adószáma + Tax number of the fiscal representative + + + + + A pénzügyi képviselő neve + Name of the fiscal representative + + + + + Pénzügyi képviselő címe + Address of the fiscal representative + + + + + Pénzügyi képviselő által a számla kibocsátó (eladó) számára megnyitott bankszámla bankszámlaszáma + Bank account number opened by the fiscal representative for the issuer of the invoice (supplier) + + + + + + + Főkönyvi számlaszámok + General ledger account numbers + + + + + Főkönyvi számlaszám + General ledger account number + + + + + + + Globális helyazonosító számok + Global location numbers + + + + + Globális helyazonosító szám + Global location number + + + + + + + Számla részletező adatok + Invoice detail data + + + + + A számla típusa, módosító okirat esetén az eredeti számla típusa + Type of invoice. In case of modification document the type of original invoice + + + + + Teljesítés dátuma (ha nem szerepel a számlán, akkor azonos a számla keltével) - ÁFA tv. 169. § g) + Delivery date (if this field does not exist on the invoice, the date of the invoice should be considered as such) - section 169 (g) of the VAT law + + + + + Amennyiben a számla egy időszakra vonatkozik, akkor az időszak első napja + The first day of the delivery, if the invoice delivery is a period + + + + + Amennyiben a számla egy időszakra vonatkozik, akkor az időszak utolsó napja + The last day of the delivery, if the invoice delivery is a period + + + + + Számviteli teljesítés dátuma. Időszak esetén az időszak utolsó napja + Date of accounting accomplishment. In the event of a period, the last day of the period + + + + + Annak jelzése, ha a felek a termékértékesítés, szolgáltatás nyújtás során időszakonkénti elszámolásban vagy fizetésben állapodnak meg, vagy a termékértékesítés, szolgáltatás nyújtás ellenértékét meghatározott időpontra állapítják meg. + Indicates where by agreement of the parties it gives rise to successive statements of account or successive payments relating to the supply of goods, or the supply of services, or if the consideration agreed upon for such goods and/or services applies to specific periods. + + + + + Kisadózó jelzése + Marking of low tax-bracket enterprise + + + + + A számla pénzneme az ISO 4217 szabvány szerint + ISO 4217 currency code on the invoice + + + + + HUF-tól különböző pénznem esetén az alkalmazott árfolyam: egy egység értéke HUF-ban + In case any currency is used other than HUF, the applied exchange rate should be mentioned: 1 unit of the foreign currency expressed in HUF + + + + + Közmű elszámoló számla jelölése (2013.évi CLXXXVIII törvény szerinti elszámoló számla) + Marking the fact of utility settlement invoice (invoice according to Act CLXXXVIII of 2013) + + + + + Önszámlázás jelölése (önszámlázás esetén true) + Marking the fact of self-billing (in the case of self-billing the value is true) + + + + + Fizetés módja + Method of payment + + + + + Fizetési határidő + Deadline for payment + + + + + Pénzforgalmi elszámolás jelölése, ha az szerepel a számlán - ÁFA tv. 169. § h). Értéke true pénzforgalmi elszámolás esetén + Marking the fact of cash accounting if this is indicated on the invoice - section 169 (h) of the VAT law. The value is true in case of cash accounting + + + + + A számla vagy módosító okirat megjelenési formája + Form of appearance of the invoice or modification document + + + + + A számlafeldolgozást segítő, egyezményesen nevesített egyéb adatok + Other conventionally named data to assist in invoice processing + + + + + A számlára vonatkozó egyéb adat + Other data in relation to the invoice + + + + + + + A számla adatszolgáltatás adatai + Invoice exchange data + + + + + Számla vagy módosító okirat sorszáma - ÁFA tv. 169. § b) vagy 170. § (1) bek. b) pont + Sequential number of the original invoice or modification document - section 169 (b) or section 170 (1) b) of the VAT law + + + + + Számla vagy módosító okirat kelte - ÁFA tv. 169. § a), ÁFA tv. 170. § (1) bek. a) + Date of issue of the invoice or the modification document - section 169 (a) of the VAT law, section 170 (1) a) of the VAT law + + + + + Jelöli, ha az adatszolgáltatás maga a számla (a számlán nem szerepel több adat) + Indicates whether the data exchange is identical with the invoice (the invoice does not contain any more data) + + + + + Számlaadatok leírására szolgáló közös típus + A common type to describe invoice information + + + + + + + Számla fejléc adatai + Data in header of invoice + + + + + Számla kibocsátó (eladó) adatai + Data related to the issuer of the invoice (supplier) + + + + + Vevő adatai + Data related to the customer + + + + + Pénzügyi képviselő adatai + Data related to the fiscal representative + + + + + Számla részletező adatok + Invoice detail adata + + + + + + + Számlaadatok leírására szolgáló közös típus + A common type to describe invoice information + + + + + Egy számla vagy módosító okirat adatai + Data of a single invoice or modification document + + + + + Kötegelt módosító okirat adatai + Data of a batch of modification documents + + + + + + + A módosítás vagy érvénytelenítés hivatkozási adatai + Modification or cancellation reference data + + + + + Az eredeti számla sorszáma, melyre a módosítás vonatkozik - ÁFA tv. 170. § (1) c) + Sequence number of the original invoice, on which the modification occurs - section 170 (1) c) of the VAT law + + + + + Annak jelzése, hogy a módosítás olyan alapszámlára hivatkozik, amelyről nem történt és nem is fog történni adatszolgáltatás + Indicates whether the modification references to an original invoice which is not and will not be exchanged + + + + + A számlára vonatkozó módosító okirat egyedi sorszáma + The unique sequence number referring to the original invoice + + + + + + + Egy számla vagy módosító okirat adatai + Data of a single invoice or modification document + + + + + A módosítás vagy érvénytelenítés adatai + Modification or cancellation data + + + + + A számla egészét jellemző adatok + Data concerning the whole invoice + + + + + A számlán szereplő tételek adatai + Product/service data appearing on the invoice + + + + + Termékdíjjal kapcsolatos összesítő adatok + Summary data of product charges + + + + + Az ÁFA törvény szerinti összesítő adatok + Summary data according to VAT law + + + + + + + Cikkszámok + Item numbers + + + + + Cikkszám + Item number + + + + + + + Normál vagy gyűjtő számla esetén kitöltendő tétel érték adatok + Item value data to be completed in case of normal or aggregate invoice + + + + + Tétel nettó adatok + Line net data + + + + + Adómérték vagy adómentesség jelölése + Tax rate or tax exemption marking + + + + + Tétel ÁFA adatok + Line VAT data + + + + + Tétel bruttó adatok + Line gross data + + + + + + + Egyszerűsített számla esetén kitöltendő tétel érték adatok + Item value data to be completed in case of simplified invoice + + + + + Adómérték vagy adómentesség jelölése + Tax rate or tax exemption marking + + + + + Tétel bruttó értéke a számla pénznemében + Gross amount of the item expressed in the currency of the invoice + + + + + Tétel bruttó értéke forintban + Gross amount of the item expressed in HUF + + + + + + + Tétel bruttó adatok + Line gross data + + + + + Tétel bruttó értéke a számla pénznemében. ÁFA tartalmú különbözeti adózás esetén az ellenérték. + Gross amount of the item expressed in the currency of the invoice. In case of margin scheme taxation containing VAT, the amount of consideration expressed in the currency of the invoice. + + + + + Tétel bruttó értéke forintban + Gross amount of the item expressed in HUF + + + + + + + Módosításról történő adatszolgáltatás esetén a tételsor módosítás jellegének jelölése + Marking the goal of modification of the line (in the case of data supply about changes/updates only) + + + + + Az eredeti számla módosítással érintett tételének sorszáma (lineNumber). Új tétel létrehozása esetén az új tétel sorszáma, a meglévő tételsorok számozásának folytatásaként + Line number of the original invoice, which the modification occurs with. In case of create operation the tag shall contain the new line number, as a sequential increment of the the existing lines set + + + + + A számlatétel módosításának jellege + Line modification type + + + + + + + Tétel nettó adatok + Line net data + + + + + Tétel nettó összege a számla pénznemében. ÁFA tartalmú különbözeti adózás esetén az ellenérték áfa összegével csökkentett értéke a számla pénznemében. + Net amount of the item expressed in the currency of the invoice. In case of margin scheme taxation containing VAT, the amount of consideration reduced with the amount of VAT, expressed in the currency of the invoice. + + + + + Tétel nettó összege forintban. ÁFA tartalmú különbözeti adózás esetén az ellenérték áfa összegével csökkentett értéke forintban. + Net amount of the item expressed in HUF. Net amount of the item expressed in the currency of the invoice. In case of margin scheme taxation containing VAT, the amount of consideration reduced with the amount of VAT, expressed in HUF. + + + + + + + Termék/szolgáltatás tételek + Product / service items + + + + + Jelöli, ha az adatszolgáltatás méretcsökkentés miatt összevont soradatokat tartalmaz + Indicates whether the data exchange contains merged line data due to size reduction + + + + + Termék/szolgáltatás tétel + Product / service item + + + + + + + A számla tételek (termék vagy szolgáltatás) adatait tartalmazó típus + Field type including data of invoice items (product or service) + + + + + A tétel sorszáma + Sequential number of the item + + + + + Módosításról történő adatszolgáltatás esetén a tételsor módosítás jellegének jelölése + Marking the goal of modification of the line (in the case of data supply about changes/updates only) + + + + + Hivatkozások kapcsolódó tételekre, ha ez az ÁFA törvény alapján szükséges + References to connected items if it is necessary according to VAT law + + + + + Előleghez kapcsolódó adatok + Advance related data + + + + + Termékkódok + Product codes + + + + + Értéke true, ha a tétel mennyiségi egysége természetes mértékegységben kifejezhető + The value is true if the unit of measure of the invoice item is expressible in natural unit + + + + + Adott tételsor termékértékesítés vagy szolgáltatás nyújtás jellegének jelzése + Indication of the nature of the supply of goods or services on a given line + + + + + A termék vagy szolgáltatás megnevezése + Name / description of the product or service + + + + + Mennyiség + Quantity + + + + + A számlán szereplő mennyiségi egység kanonikus kifejezése az interfész specifikáció szerint + Canonical representation of the unit of measure of the invoice, according to the interface specification + + + + + A számlán szereplő mennyiségi egység literális kifejezése + Literal unit of measure of the invoice + + + + + Egységár a számla pénznemében. Egyszerűsített számla esetén bruttó, egyéb esetben nettó egységár + Unit price expressed in the currency of the invoice In the event of simplified invoices gross unit price, in other cases net unit price + + + + + Egységár forintban + Unit price expressed in HUF + + + + + A tételhez tartozó árengedmény adatok + Discount data in relation to the item + + + + + + Normál (nem egyszerűsített) számla esetén (beleértve a gyűjtőszámlát) kitöltendő tétel érték adatok. + Item value data to be completed in case of normal (not simplified, but including aggregated) invoice + + + + + Egyszerűsített számla esetén kitöltendő tétel érték adatok + Item value data to be completed in case of simplified invoice + + + + + + Értéke true ha a tétel közvetített szolgáltatás - Számviteli tv. 3.§ (4) 1 + The value is true if the item is an intermediated service - paragraph (4) 1 of the Article 3 of Accounting Act + + + + + Gyűjtő számla adatok + Aggregate invoice data + + + + + Új közlekedési eszköz értékesítés ÁFA tv. 89 § ill. 169 § o) + Supply of new means of transport - section 89 § and 169 (o) of the VAT law + + + + + Értéke true, ha a tétel betétdíj jellegű + The value is true if the item is bottle/container deposit + + + + + Értéke true ha a tételt termékdíj fizetési kötelezettség terheli + The value is true if the item is liable to product fee + + + + + Földgáz, villamos energia, szén jövedéki adója forintban - Jöt. 118. § (2) + Excise duty on natural gas, electricity and coal in Hungarian forints – paragraph (2), Section 118 of the Act on Excise Duties + + + + + Gázolaj adózottan történő beszerzésének adatai – 45/2016 (XI. 29.) NGM rendelet 75. § (1) a) + Data of gas oil acquisition after taxation – point a), paragraph (1) of Section 75 of the NGM Decree No. 45/2016. (XI. 29.) + + + + + Értéke true, ha a Neta tv-ben meghatározott adókötelezettség az adó alanyát terheli. 2011. évi CIII. tv. 3.§(2) + Value is true, if the taxable person is liable for tax obligation determined in the Act on Public Health Product Tax (Neta tv.). Paragraph (2), Section 3 of the Act CIII of 2011 + + + + + A környezetvédelmi termékdíjról szóló 2011. évi LXXXV. tv. szerinti, tételre vonatkozó záradékok + Clauses according to the Act LXXXV of 2011 on Environmental Protection Product Fee (related to the item) + + + + + A tétel termékdíj tartalmára vonatkozó adatok + Data on the content of the line's product charge + + + + + A számlafeldolgozást segítő, egyezményesen nevesített egyéb adatok + Other conventionally named data to assist in invoice processing + + + + + A termék/szolgáltatás tételhez kapcsolódó, további adat + Other data in relation to the product / service item + + + + + + + Tétel ÁFA adatok + Line VAT data + + + + + Tétel ÁFA összege a számla pénznemében + VAT amount of the item expressed in the currency of the invoice + + + + + Tétel ÁFA összege forintban + VAT amount of the item expressed in HUF + + + + + + + Anyagszámok + Material numbers + + + + + Anyagszám + Material number + + + + + + + Új közlekedési eszköz értékesítés ÁFA tv. 89 § ill. 169 § o) + Supply of new means of transport - section 89 § and 169 (o) of the VAT law + + + + + Gyártmány/típus + Product / type + + + + + Alvázszám/gyári szám/Gyártási szám + Chassis number / serial number / product number + + + + + Motorszám + Engine number + + + + + Első forgalomba helyezés időpontja + First entry into service + + + + + + Szárazföldi közlekedési eszköz további adatai + Other data in relation to motorised land vehicle + + + + + Vízi jármű adatai + Data of vessel + + + + + Légi közlekedési eszköz + Aircraft + + + + + + + + Megrendelésszámok + Order numbers + + + + + Megrendelésszám + Order number + + + + + + + A termékdíj bevallását igazoló dokumentum adatai a 2011. évi LXXXV. tv. 13. § (3) szerint és a 25. § (3) szerint + Data of the document verifying the declaration submitted on the product fee according to the Paragraph (3), Section 13 and the Paragraph (3) Section 25 of the Act LXXXV of 2011 + + + + + Számla sorszáma vagy egyéb okirat azonosító száma + Sequential number of the invoice, or other document considered as such + + + + + Számla kelte + Date of issue of the invoice + + + + + Kötelezett neve + Name of obligator + + + + + Kötelezett címe + Address of obligator + + + + + A kötelezett adószáma + Tax number of obligated + + + + + + + Termékkódok + Product codes + + + + + Termékkód + Product code + + + + + + + Különböző termék- vagy szolgáltatáskódokat tartalmazó típus + Field type including the different product and service codes + + + + + A termékkód fajtájának (pl. VTSZ, CsK, stb.) jelölése + The kind of product code (f. ex. VTSZ, CsK, etc.) + + + + + + A termékkód értéke nem saját termékkód esetén + The value of (not own) product code + + + + + Saját termékkód értéke + Own product code value + + + + + + + + A környezetvédelmi termékdíjról szóló 2011. évi LXXXV. tv. szerinti, tételre vonatkozó záradékok + Clauses according to the Act LXXXV of 2011 on Environmental Protection Product Fee (related to the item) + + + + + A környezetvédelmi termékdíj kötelezettség átvállalásával kapcsolatos adatok + Data in connection with takeover of environmental protection product fee + + + + + Ha az eladó a vevő nyilatkozata alapján mentesül a termékdíj megfizetése alól, akkor az érintett termékáram + Should the supplier, based on statement given by the purchaser, be exempted from paying product fee, then the product stream affected + + + + + + + Termékdíj adatok + Product charges data + + + + + Termékdíj kód (Kt vagy Csk) + Product charges code (Kt or Csk code) + + + + + A termékdíjjal érintett termék mennyisége + Quantity of product, according to product charge + + + + + A díjtétel egysége (kg vagy darab) + Unit of the rate (kg or piece) + + + + + A termékdíj díjtétele (HUF/egység) + Product fee rate (HUF/unit) + + + + + Termékdíj összege forintban + Amount in Hungarian forints of the product fee + + + + + + + Termékdíj összegzés adatok + Summary of product charges + + + + + Annak jelzése, hogy a termékdíj összesítés visszaigénylésre (REFUND) vagy raktárba történő beszállításra (DEPOSIT) vonatkozik + Indicating whether the the product fee summary concerns refund or deposit + + + + + Termékdíj adatok + Product charges data + + + + + Termékdíj összesen + Aggregate product charges + + + + + A termékdíj bevallását igazoló dokumentum adatai a 2011. évi LXXXV. tv. 13. § (3) szerint és a 25. § (3) szerint + Data of the document verifying the declaration submitted on the product fee according to the Paragraph (3), Section 13 and the Paragraph (3) Section 25 of the Act LXXXV of 2011 + + + + + + + A környezetvédelmi termékdíj kötelezettség átvállalásával kapcsolatos adatok + Data in connection with takeover of environmental protection product fee + + + + + Az átvállalás iránya és jogszabályi alapja + Direction and legal base of takeover + + + + + Az átvállalt termékdíj összege forintban, ha a vevő vállalja át az eladó termékdíj-kötelezettségét + Amount in Hungarian forints of the product fee taken over if the purchaser takes over the supplier’s product fee liability + + + + + + + Projektszámok + Project numbers + + + + + Projektszám + Project number + + + + + + + Hivatkozások kapcsolódó tételekre, ha ez az ÁFA törvény alapján szükséges + References to connected items if it is necessary according to VAT law + + + + + Hivatkozások kapcsolódó tételekre, ha ez az ÁFA törvény alapján szükséges + References to connected items if it is necessary according to VAT law + + + + + + + Szállítási dátumok + Shipping dates + + + + + Szállítási dátum + Shipping date + + + + + + + ÁFA mértékek szerinti összesítés + Summary according to VAT rates + + + + + Adómérték vagy adómentesség jelölése + Marking the tax rate or the fact of tax exemption + + + + + Adott adómértékhez tartozó nettó adatok + Net data of given tax rate + + + + + Adott adómértékhez tartozó ÁFA adatok + VAT data of given tax rate + + + + + Adott adómértékhez tartozó bruttó adatok + Gross data of given tax rate + + + + + + + A számla összesítő bruttó adatai + Gross data of the invoice summary + + + + + A számla bruttó összege a számla pénznemében + Gross amount of the invoice expressed in the currency of the invoice + + + + + A számla bruttó összege forintban + Gross amount of the invoice expressed in HUF + + + + + + + Számla összesítés (nem egyszerűsített számla esetén) + Calculation of invoice totals (not simplified invoice) + + + + + Összesítés ÁFA-mérték szerint + Calculation of invoice totals per VAT rates + + + + + A számla nettó összege a számla pénznemében + Net amount of the invoice expressed in the currency of the invoice + + + + + A számla nettó összege forintban + Net amount of the invoice expressed in HUF + + + + + A számla ÁFA összege a számla pénznemében + VAT amount of the invoice expressed in the currency of the invoice + + + + + A számla ÁFA összege forintban + VAT amount of the invoice expressed in HUF + + + + + + + Egyszerűsített számla összesítés + Calculation of simplified invoice totals + + + + + Adómérték vagy adómentesség jelölése + Marking the tax rate or the fact of tax exemption + + + + + Az adott adótartalomhoz tartozó értékesítés vagy szolgáltatásnyújtás bruttó összege a számla pénznemében + The gross amount of the sale or service for the given tax amount in the currency of the invoice + + + + + Az adott adótartalomhoz tartozó értékesítés vagy szolgáltatásnyújtás bruttó összege forintban + The gross amount of the sale or service for the given tax amount in HUF + + + + + + + Számla összesítés adatai + Data of calculation of invoice totals + + + + + + Számla összesítés (nem egyszerűsített számla esetén) + Calculation of invoice totals (not simplified invoice) + + + + + Egyszerűsített számla összesítés + Calculation of simplified invoice totals + + + + + + A számla összesítő bruttó adatai + Gross data of the invoice summary + + + + + + + Az eladó vállalati kódjai + Company codes of the supplier + + + + + Az eladó vállalati kódja + Company code of the supplier + + + + + + + A szállító (eladó) adatai + Invoice supplier (seller) data + + + + + Belföldi adószám vagy csoportazonosító szám + Tax number or group identification number + + + + + Csoport tag adószáma, ha a termékbeszerzés vagy szolgáltatás nyújtása csoportazonosító szám alatt történt + Tax number of group member, when the supply of goods or services is done under group identification number + + + + + Közösségi adószám + Community tax number + + + + + Az eladó (szállító) neve + Name of the seller (supplier) + + + + + Az eladó (szállító) címe + Address of the seller (supplier) + + + + + Az eladó (szállító) bankszámlaszáma + Bank account number of the seller (supplier) + + + + + Értéke true, amennyiben az eladó (szállító) alanyi ÁFA mentes + Value is true if the seller (supplier) is individually exempted from VAT + + + + + Az eladó adóraktári engedélyének vagy jövedéki engedélyének száma (2016. évi LXVIII. tv.) + Number of supplier’s tax warehouse license or excise license (Act LXVIII of 2016) + + + + + + + Adóalap és felszámított adó eltérésének adatai + Data of mismatching tax base and levied tax + + + + + Adómérték, adótartalom + VAT rate, VAT content + + + + + Az eset leírása kóddal + Case notation with code + + + + + + + Adott adómértékhez tartozó bruttó adatok + Gross data of given tax rate + + + + + Az adott adómértékhez tartozó értékesítés vagy szolgáltatásnyújtás bruttó összege a számla pénznemében + Gross amount of sales or service delivery under a given tax rate expressed in the currency of the invoice + + + + + Az adott adómértékhez tartozó értékesítés vagy szolgáltatásnyújtás bruttó összege forintban + Gross amount of sales or service delivery under a given tax rate expressed in HUF + + + + + + + Adott adómértékhez tartozó nettó adatok + Net data of given tax rate + + + + + Az adott adómértékhez tartozó értékesítés vagy szolgáltatásnyújtás nettó összege a számla pénznemében + Net amount of sales or service delivery under a given tax rate expressed in the currency of the invoice + + + + + Az adott adómértékhez tartozó értékesítés vagy szolgáltatásnyújtás nettó összege forintban + Net amount of sales or service delivery under a given tax rate expressed in HUF + + + + + + + Az adómérték vagy az adómentes értékesítés jelölése + Marking tax rate or tax exempt supply + + + + + Az alkalmazott adó mértéke - ÁFA tv. 169. § j) + Applied tax rate - section 169 (j) of the VAT law + + + + + ÁFA tartalom egyszerűsített számla esetén + VAT content in case of simplified invoice + + + + + Az adómentesség jelölése - ÁFA tv. 169. § m) + Marking tax exemption - section 169 (m) of the VAT law + + + + + Az ÁFA törvény hatályán kívüli + Out of scope of the VAT law + + + + + A belföldi fordított adózás jelölése - ÁFA tv. 142. § + Marking the national is reverse charge taxation - section 142 of the VAT law + + + + + Különbözet szerinti szabályozás jelölése - ÁFA tv. 169. § p) q) + Marking the margin-scheme taxation as per section 169 (p)(q) + + + + + Adóalap és felszámított adó eltérésének esetei + Different cases of mismatching tax base and levied tax + + + + + Nincs felszámított áfa a 17. § alapján + No VAT charged under Section 17 + + + + + + + Adott adómértékhez tartozó ÁFA adatok + VAT data of given tax rate + + + + + Az adott adómértékhez tartozó értékesítés vagy szolgáltatásnyújtás ÁFA összege a számla pénznemében + VAT amount of sales or service delivery under a given tax rate expressed in the currency of the invoice + + + + + Az adott adómértékhez tartozó értékesítés vagy szolgáltatásnyújtás ÁFA összege forintban + VAT amount of sales or service delivery under a given tax rate expressed in HUF + + + + + + + Szárazföldi közlekedési eszköz további adatai + Other data in relation to motorised land vehicle + + + + + Hengerűrtartalom köbcentiméterben + Engine capacity in cubic centimetre + + + + + Teljesítmény kW-ban + Engine power in kW + + + + + Futott kilométerek száma + Travelled distance in km + + + + + + + Vízi jármű adatai + Data of vessel + + + + + Hajó hossza méterben + Length of hull in metre + + + + + Értéke true, ha a jármű az ÁFA tv. 259.§ 25. b) szerinti kivétel alá tartozik + The value is true if the means of transport is exempt from VAT as per section 259 [25] (b) + + + + + Hajózott órák száma + Number of sailed hours + + + + + + + XML root element, számla vagy módosítás adatait leíró típus, amelyet BASE64 kódoltan tartalmaz az invoiceApi sémaleíró invoiceData elementje + XML root element, invoice or modification data type in BASE64 encoding, equivalent with the invoiceApi schema definition's invoiceData element + + + + + + + + diff --git a/vatrate.go b/vatrate.go index 272c710..c249f73 100644 --- a/vatrate.go +++ b/vatrate.go @@ -2,7 +2,7 @@ package nav import ( "github.com/invopop/gobl/bill" - "github.com/invopop/gobl/regimes/hu" + //"github.com/invopop/gobl/regimes/hu" "github.com/invopop/gobl/tax" ) @@ -39,105 +39,113 @@ type taxInfo struct { antique bool } -func NewVatRate(obj any, info *taxInfo) *VatRate { +func NewVatRate(obj any, info *taxInfo) (*VatRate, error) { switch obj := obj.(type) { case *tax.RateTotal: return newVatRateTotal(obj, info) case *tax.Combo: return newVatRateCombo(obj, info) } - return nil + return nil, nil } // NewVatRate creates a new VatRate from a taxid -func newVatRateTotal(rate *tax.RateTotal, info *taxInfo) *VatRate { +func newVatRateTotal(rate *tax.RateTotal, info *taxInfo) (*VatRate, error) { // First if it is not exent or simplified invoice we can return the percentage if rate.Percent != nil { - return &VatRate{VatPercentage: rate.Percent.Amount().Rescale(4).Float64()} + return &VatRate{VatPercentage: rate.Percent.Amount().Rescale(4).Float64()}, nil } // If it is a simplified invoice we can return the content if info.simplifiedInvoice { - return &VatRate{VatContent: rate.Amount.Rescale(4).Float64()} + return &VatRate{VatContent: rate.Amount.Rescale(4).Float64()}, nil } // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode for k, v := range rate.Ext { - if k == hu.ExtKeyExemptionCode { - return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}} + if k == "hu-exemption-code" { //hu.ExtKeyExemptionCode { + return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}}, nil } - if k == hu.ExtKeyVatOutOfScopeCode { - return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}} + if k == "hu-vat-out-of-scope-code" { //hu.ExtKeyVatOutOfScopeCode { + return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}}, nil } } // Check if it is a domestic reverse charge if info.domesticReverseCharge { - return &VatRate{VatDomesticReverseCharge: true} + return &VatRate{VatDomesticReverseCharge: true}, nil } // Check the margin scheme indicators if info.travelAgency { - return &VatRate{MarginSchemeIndicator: "TRAVEL_AGENCY"} + return &VatRate{MarginSchemeIndicator: "TRAVEL_AGENCY"}, nil } if info.secondHand { - return &VatRate{MarginSchemeIndicator: "SECOND_HAND"} + return &VatRate{MarginSchemeIndicator: "SECOND_HAND"}, nil } if info.art { - return &VatRate{MarginSchemeIndicator: "ARTWORK"} + return &VatRate{MarginSchemeIndicator: "ARTWORK"}, nil } if info.antique { - return &VatRate{MarginSchemeIndicator: "ANTIQUE"} + return &VatRate{MarginSchemeIndicator: "ANTIQUE"}, nil } // Missing vat amount mismatch - return &VatRate{NoVatCharge: true} + // If percent is nil + if rate.Percent == nil { + return &VatRate{NoVatCharge: true}, nil + } + + return nil, ErrNoVatRateField } -func newVatRateCombo(c *tax.Combo, info *taxInfo) *VatRate { +func newVatRateCombo(c *tax.Combo, info *taxInfo) (*VatRate, error) { // First if it is not exent or simplified invoice we can return the percentage if c.Percent != nil { - return &VatRate{VatPercentage: c.Percent.Amount().Rescale(4).Float64()} + return &VatRate{VatPercentage: c.Percent.Amount().Rescale(4).Float64()}, nil } // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode for k, v := range c.Ext { - if k == hu.ExtKeyExemptionCode { - return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}} + if k == "hu-exemption-code" { //hu.ExtKeyExemptionCode { + return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}}, nil } - if k == hu.ExtKeyVatOutOfScopeCode { - return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}} + if k == "hu-vat-out-of-scope-code" { //hu.ExtKeyVatOutOfScopeCode { + return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}}, nil } } // Check if it is a domestic reverse charge if info.domesticReverseCharge { - return &VatRate{VatDomesticReverseCharge: true} + return &VatRate{VatDomesticReverseCharge: true}, nil } // Check the margin scheme indicators - if info.travelAgency { - return &VatRate{MarginSchemeIndicator: "TRAVEL_AGENCY"} + return &VatRate{MarginSchemeIndicator: "TRAVEL_AGENCY"}, nil } if info.secondHand { - return &VatRate{MarginSchemeIndicator: "SECOND_HAND"} + return &VatRate{MarginSchemeIndicator: "SECOND_HAND"}, nil } if info.art { - return &VatRate{MarginSchemeIndicator: "ARTWORK"} + return &VatRate{MarginSchemeIndicator: "ARTWORK"}, nil } if info.antique { - return &VatRate{MarginSchemeIndicator: "ANTIQUE"} + return &VatRate{MarginSchemeIndicator: "ANTIQUE"}, nil } // Missing vat amount mismatch - return &VatRate{NoVatCharge: true} + if c.Percent == nil { + return &VatRate{NoVatCharge: true}, nil + } + + return nil, ErrNoVatRateField } // Until PR approved in regimes this wont work @@ -148,15 +156,15 @@ func newTaxInfo(inv *bill.Invoice) *taxInfo { switch scheme { case tax.TagSimplified: info.simplifiedInvoice = true - case hu.TagDomesticReverseCharge: + case "domestic-reverse-charge": //case hu.TagDomesticReverseCharge: info.domesticReverseCharge = true - case hu.TagTravelAgency: + case "travel-agency": //hu.TagTravelAgency: info.travelAgency = true - case hu.TagSecondHand: + case "second-hand": //hu.TagSecondHand: info.secondHand = true - case hu.TagArt: + case "art": //hu.TagArt: info.art = true - case hu.TagAntique: + case "antiques": //hu.TagAntique: info.antique = true } } From 093b7ac36467d6ce652565d0194922f9eb7de6b9 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Mon, 19 Aug 2024 11:24:18 +0000 Subject: [PATCH 09/27] Adding vatrate test --- lines.go | 11 ++--- vatrate_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 vatrate_test.go diff --git a/lines.go b/lines.go index afa02b5..f88a985 100644 --- a/lines.go +++ b/lines.go @@ -148,17 +148,18 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { vatCombo := line.Taxes.Get(tax.CategoryVAT) if vatCombo != nil { - vatRate, err := NewVatRate(vatCombo, info) - if err != nil { - return nil, err - } if info.simplifiedInvoice { + vatAmount := line.Total.Multiply(vatCombo.Percent.Amount()) lineNav.LineAmountsSimplified = &LineAmountsSimplified{ - LineVatRate: vatRate, + LineVatRate: &VatRate{VatContent: vatAmount.Rescale(4).Float64()}, LineGrossAmountSimplified: line.Total.Rescale(2).Float64(), LineGrossAmountSimplifiedHUF: amountToHUF(line.Total, rate).Float64(), } } else { + vatRate, err := NewVatRate(vatCombo, info) + if err != nil { + return nil, err + } lineNav.LineAmountsNormal = &LineAmountsNormal{ LineNetAmountData: LineNetAmountData{ LineNetAmount: line.Total.Rescale(2).Float64(), diff --git a/vatrate_test.go b/vatrate_test.go new file mode 100644 index 0000000..8270f47 --- /dev/null +++ b/vatrate_test.go @@ -0,0 +1,111 @@ +package nav + +import ( + "testing" + + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestNewVatRate(t *testing.T) { + tests := []struct { + name string + input any + info *taxInfo + expected *VatRate + expectErr bool + }{ + { + name: "RateTotal with Percentage", + input: &tax.RateTotal{ + Percent: num.NewPercentage(27, 4), + }, + info: &taxInfo{}, + expected: &VatRate{ + VatPercentage: 0.27, + }, + expectErr: false, + }, + { + name: "RateTotal with Simplified Invoice", + input: &tax.RateTotal{ + Amount: num.MakeAmount(100, 0), + }, + info: &taxInfo{simplifiedInvoice: true}, + expected: &VatRate{ + VatContent: 100, + }, + expectErr: false, + }, + { + name: "RateTotal with Exemption Code", + input: &tax.RateTotal{ + Ext: tax.Extensions{ + "hu-exemption-code": "AAM", + }, + }, + info: &taxInfo{}, + expected: &VatRate{ + VatExemption: &DetailedReason{ + Case: "AAM", + Reason: "Exempt", + }, + }, + expectErr: false, + }, + { + name: "RateTotal with Out of Scope Code", + input: &tax.RateTotal{ + Ext: tax.Extensions{ + "hu-vat-out-of-scope-code": "ATK", + }, + }, + info: &taxInfo{}, + expected: &VatRate{ + VatOutOfScope: &DetailedReason{ + Case: "ATK", + Reason: "Out of Scope", + }, + }, + expectErr: false, + }, + { + name: "RateTotal with Domestic Reverse Charge", + input: &tax.RateTotal{}, + info: &taxInfo{domesticReverseCharge: true}, + expected: &VatRate{ + VatDomesticReverseCharge: true, + }, + expectErr: false, + }, + { + name: "RateTotal with No VAT Charge", + input: &tax.RateTotal{}, + info: &taxInfo{}, + expected: &VatRate{ + NoVatCharge: true, + }, + expectErr: false, + }, + { + name: "Invalid Type Input", + input: "invalid", + info: &taxInfo{}, + expected: nil, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + vatRate, err := NewVatRate(tt.input, tt.info) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, vatRate) + } + }) + } +} From 71c831ace1e8a2cfc75778013f7fb25a8bb786e8 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Mon, 19 Aug 2024 11:36:10 +0000 Subject: [PATCH 10/27] Test lines --- lines_test.go | 149 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 lines_test.go diff --git a/lines_test.go b/lines_test.go new file mode 100644 index 0000000..1017422 --- /dev/null +++ b/lines_test.go @@ -0,0 +1,149 @@ +package nav + +import ( + "testing" + + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/org" + "github.com/invopop/gobl/tax" + "github.com/stretchr/testify/assert" +) + +func TestNewInvoiceLines(t *testing.T) { + invoice := &bill.Invoice{ + Currency: currency.HUF, + Lines: []*bill.Line{ + { + Index: 1, + Item: &org.Item{ + Identities: []*org.Identity{ + {Type: "VTSZ", Code: cbc.Code("12345")}, + }, + Unit: org.UnitKilogram, + Key: "PRODUCT", + }, + Total: num.AmountFromFloat64(1000, 2), + Taxes: tax.Set{ + {Category: tax.CategoryVAT, Percent: num.NewPercentage(27, 4)}, + }, + }, + }, + Tax: &bill.Tax{Tags: []cbc.Key{tax.TagSimplified}}, + } + + invoiceLines, err := NewInvoiceLines(invoice) + assert.NoError(t, err) + assert.NotNil(t, invoiceLines) + assert.False(t, invoiceLines.MergedItemIndicator) + assert.Len(t, invoiceLines.Lines, 1) + + line := invoiceLines.Lines[0] + assert.Equal(t, 1, line.LineNumber) + assert.NotNil(t, line.ProductCodes) + assert.Equal(t, "KILOGRAM", line.UnitOfMeasure) + assert.Equal(t, "PRODUCT", line.LineNatureIndicator) + assert.NotNil(t, line.LineAmountsSimplified) + assert.Nil(t, line.LineAmountsNormal) +} + +func TestNewLine_NormalInvoice(t *testing.T) { + line := &bill.Line{ + Index: 1, + Item: &org.Item{ + Identities: []*org.Identity{ + {Type: "VTSZ", Code: cbc.Code("12345")}, + }, + Unit: org.UnitKilogram, + Key: "PRODUCT", + }, + Total: num.AmountFromFloat64(1000, 2), + Taxes: tax.Set{ + {Category: tax.CategoryVAT, Percent: num.NewPercentage(27, 4)}, + }, + } + + taxInfo := &taxInfo{} + rate := 1.0 + + lineNav, err := NewLine(line, taxInfo, rate) + assert.NoError(t, err) + assert.NotNil(t, lineNav) + assert.Equal(t, 1, lineNav.LineNumber) + assert.Equal(t, "KILOGRAM", lineNav.UnitOfMeasure) + assert.Equal(t, "PRODUCT", lineNav.LineNatureIndicator) + assert.NotNil(t, lineNav.LineAmountsNormal) + assert.Nil(t, lineNav.LineAmountsSimplified) +} + +func TestNewLine_SimplifiedInvoice(t *testing.T) { + line := &bill.Line{ + Index: 1, + Item: &org.Item{ + Identities: []*org.Identity{ + {Type: "VTSZ", Code: cbc.Code("12345")}, + }, + Unit: org.UnitKilogram, + Key: "PRODUCT", + }, + Total: num.AmountFromFloat64(1000, 2), + Taxes: tax.Set{ + {Category: tax.CategoryVAT, Percent: num.NewPercentage(27, 4)}, + }, + } + + taxInfo := &taxInfo{simplifiedInvoice: true} + rate := 1.0 + + lineNav, err := NewLine(line, taxInfo, rate) + assert.NoError(t, err) + assert.NotNil(t, lineNav) + assert.Equal(t, 1, lineNav.LineNumber) + assert.Equal(t, "KILOGRAM", lineNav.UnitOfMeasure) + assert.Equal(t, "PRODUCT", lineNav.LineNatureIndicator) + assert.NotNil(t, lineNav.LineAmountsSimplified) + assert.Nil(t, lineNav.LineAmountsNormal) +} + +func TestNewProductCodes(t *testing.T) { + identities := []*org.Identity{ + {Type: "VTSZ", Code: cbc.Code("12345")}, + {Type: "OWN", Code: cbc.Code("OWN123")}, + } + + productCodes := NewProductCodes(identities) + assert.NotNil(t, productCodes) + assert.Len(t, productCodes.ProductCode, 2) + + assert.Equal(t, "VTSZ", productCodes.ProductCode[0].ProductCodeCategory) + assert.Equal(t, "12345", productCodes.ProductCode[0].ProductCodeValue) + + assert.Equal(t, "OWN", productCodes.ProductCode[1].ProductCodeCategory) + assert.Equal(t, "OWN123", productCodes.ProductCode[1].ProductCodeOwnValue) +} + +func TestNewProductCode(t *testing.T) { + identity := &org.Identity{Type: "VTSZ", Code: cbc.Code("12345")} + productCode := NewProductCode(identity) + assert.NotNil(t, productCode) + assert.Equal(t, "VTSZ", productCode.ProductCodeCategory) + assert.Equal(t, "12345", productCode.ProductCodeValue) + + identity = &org.Identity{Type: "OWN", Code: cbc.Code("OWN123")} + productCode = NewProductCode(identity) + assert.NotNil(t, productCode) + assert.Equal(t, "OWN", productCode.ProductCodeCategory) + assert.Equal(t, "OWN123", productCode.ProductCodeOwnValue) +} + +func TestAmountToHUF(t *testing.T) { + amount := num.AmountFromFloat64(1000, 2) + exchangeRate := 300.0 + + convertedAmount := amountToHUF(amount, exchangeRate) + expectedAmount := num.AmountFromFloat64(300000, 2) + + assert.Equal(t, expectedAmount, convertedAmount) +} From 3a293c1671c8f197364e9b7254c775e647f7fe32 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Mon, 19 Aug 2024 12:46:16 +0000 Subject: [PATCH 11/27] Adding optional but common fields to lines --- README.md | 4 +- invoice.go | 8 ++- lines.go | 69 +++++++++++++++------ lines_test.go | 165 +++++++++++++++++++++----------------------------- summary.go | 7 +-- 5 files changed, 133 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index d33b5e4..d4c86fd 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,6 @@ The invoice data content of the data report must be embedded, encoded in BASE64 - We don't support modification of invoices - We don't support fiscal representatives - We don't support aggregate invoices -- In the VAT rate we are missing the vat amount mismatch field (used when VAT has been charged under section 11 or 14) \ No newline at end of file +- In the VAT rate we are missing the vat amount mismatch field (used when VAT has been charged under section 11 or 14) +- We don't support refund product charges (Field Product Fee Summary in the Invoice) +- For the moment we don't support product discounts \ No newline at end of file diff --git a/invoice.go b/invoice.go index 10fc8ec..d9b2d1d 100644 --- a/invoice.go +++ b/invoice.go @@ -24,6 +24,11 @@ func NewInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { return nil, err } + invoiceLines, err := NewInvoiceLines(inv) + if err != nil { + return nil, err + } + //invoiceSummary, err := NewInvoiceSummary(inv) if err != nil { return nil, err @@ -31,7 +36,8 @@ func NewInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { return &InvoiceMain{ Invoice: &Invoice{ - InvoiceHead: invoiceHead, + InvoiceHead: invoiceHead, + InvoiceLines: invoiceLines, //InvoiceSummary: invoiceSummary, }, }, nil diff --git a/lines.go b/lines.go index f88a985..cc89e33 100644 --- a/lines.go +++ b/lines.go @@ -18,18 +18,18 @@ type Line struct { //LineModificationReference LineModificationReference `xml:"lineModificationReference,omitempty"` //ReferencesToOtherLines []ReferenceToOtherLine `xml:"referencesToOtherLines,omitempty"` // References to related items //AdvanceData AdvanceData `xml:"advanceData,omitempty"` // Data related to advanced payment - ProductCodes *ProductCodes `xml:"productCodes,omitempty"` // Product codes - LineExpressionIndicator bool `xml:"lineExpressionIndicator"` // true if the quantity unit of the item can be expressed as a natural unit of measurement - LineNatureIndicator string `xml:"lineNatureIndicator,omitempty"` // Denotes sale of product or service - LineDescription string `xml:"lineDescription,omitempty"` - Quantity float64 `xml:"quantity,omitempty"` - UnitOfMeasure string `xml:"unitOfMeasure,omitempty"` - UnitOfMeasureOwn string `xml:"unitOfMeasureOwn,omitempty"` // Own quantity unit - UnitPrice float64 `xml:"unitPrice,omitempty"` - UnitPriceHUF float64 `xml:"unitPriceHUF,omitempty"` - //LineDiscountData DiscountData `xml:"lineDiscountData,omitempty"` - LineAmountsNormal *LineAmountsNormal `xml:"lineAmountsNormal,omitempty"` // For normal or aggregate invoices - LineAmountsSimplified *LineAmountsSimplified `xml:"lineAmountsSimplified,omitempty"` // For simplified invoices + ProductCodes *ProductCodes `xml:"productCodes,omitempty"` // Product codes + LineExpressionIndicator bool `xml:"lineExpressionIndicator"` // true if the quantity unit of the item can be expressed as a natural unit of measurement + LineNatureIndicator string `xml:"lineNatureIndicator,omitempty"` // Denotes sale of product or service + LineDescription string `xml:"lineDescription,omitempty"` + Quantity float64 `xml:"quantity,omitempty"` + UnitOfMeasure string `xml:"unitOfMeasure,omitempty"` + UnitOfMeasureOwn string `xml:"unitOfMeasureOwn,omitempty"` // Own quantity unit + UnitPrice float64 `xml:"unitPrice,omitempty"` + UnitPriceHUF float64 `xml:"unitPriceHUF,omitempty"` + LineDiscountData *LineDiscountData `xml:"lineDiscountData,omitempty"` + LineAmountsNormal *LineAmountsNormal `xml:"lineAmountsNormal,omitempty"` // For normal or aggregate invoices + LineAmountsSimplified *LineAmountsSimplified `xml:"lineAmountsSimplified,omitempty"` // For simplified invoices //IntermediatedService bool `xml:"intermediatedService,omitempty"` // true if indirect service //AggregateInvoiceLineData AggregateInvoiceLineData `xml:"aggregateInvoiceLineData,omitempty"` // Aggregate invoice data //NewTransportMean NewTransportMean `xml:"newTransportMean,omitempty"` // Sale of new means of transport @@ -55,11 +55,17 @@ type ProductCode struct { ProductCodeOwnValue string `xml:"productCodeOwnValue,omitempty"` // Own product code value } +type LineDiscountData struct { + DiscountDescription string `xml:"discountDescription"` + DiscountValue float64 `xml:"discountValue"` + DiscountRate float64 `xml:"discountRate"` +} + type LineAmountsNormal struct { - LineNetAmountData LineNetAmountData `xml:"lineNetAmountData"` - LineVatRate *VatRate `xml:"lineVatRate"` - LineVatData LineVatData `xml:"lineVatData,omitempty"` - LineGrossAmountData LineGrossAmountData `xml:"lineGrossAmountData,omitempty"` + LineNetAmountData *LineNetAmountData `xml:"lineNetAmountData"` + LineVatRate *VatRate `xml:"lineVatRate"` + LineVatData *LineVatData `xml:"lineVatData,omitempty"` + LineGrossAmountData *LineGrossAmountData `xml:"lineGrossAmountData,omitempty"` } type LineNetAmountData struct { @@ -72,6 +78,7 @@ type LineVatData struct { LineVatAmountHUF float64 `xml:"lineVatAmountHUF"` } +// LineGrossAmountData is the Net amount + VAT amount (Not mandatory) type LineGrossAmountData struct { LineGrossAmount float64 `xml:"lineGrossAmount"` LineGrossAmountHUF float64 `xml:"lineGrossAmountHUF"` @@ -79,7 +86,7 @@ type LineGrossAmountData struct { type LineAmountsSimplified struct { LineVatRate *VatRate `xml:"lineVatRate"` - LineGrossAmountSimplified float64 `xml:"lineGrossAmountSimplified"` + LineGrossAmountSimplified float64 `xml:"lineGrossAmountSimplified"` //This amount is the net amount of the normal line LineGrossAmountSimplifiedHUF float64 `xml:"lineGrossAmountSimplifiedHUF"` } @@ -117,6 +124,10 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { lineNav := &Line{ LineNumber: line.Index, LineExpressionIndicator: false, + LineDescription: line.Item.Name, + UnitPrice: line.Item.Price.Float64(), + UnitPriceHUF: amountToHUF(line.Item.Price, rate).Float64(), + Quantity: line.Quantity.Float64(), } if line.Item.Identities != nil { @@ -146,6 +157,16 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { } } + if line.Discounts != nil { + discount := &LineDiscountData{} + discount.DiscountDescription = "" + for _, dis := range line.Discounts { + discount.DiscountDescription += dis.Reason + ". " + discount.DiscountValue += dis.Amount.Float64() + } + lineNav.LineDiscountData = discount + } + vatCombo := line.Taxes.Get(tax.CategoryVAT) if vatCombo != nil { if info.simplifiedInvoice { @@ -160,12 +181,24 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { if err != nil { return nil, err } + vatAmount := num.AmountZero + if vatCombo.Percent != nil { + vatAmount = line.Total.Multiply(vatCombo.Percent.Amount()).Rescale(2) + } lineNav.LineAmountsNormal = &LineAmountsNormal{ - LineNetAmountData: LineNetAmountData{ + LineNetAmountData: &LineNetAmountData{ LineNetAmount: line.Total.Rescale(2).Float64(), LineNetAmountHUF: amountToHUF(line.Total, rate).Float64(), }, LineVatRate: vatRate, + LineVatData: &LineVatData{ + LineVatAmount: vatAmount.Float64(), + LineVatAmountHUF: amountToHUF(vatAmount, rate).Float64(), + }, + LineGrossAmountData: &LineGrossAmountData{ + LineGrossAmount: line.Total.Add(vatAmount).Rescale(2).Float64(), + LineGrossAmountHUF: amountToHUF(line.Total.Add(vatAmount), rate).Float64(), + }, } } } diff --git a/lines_test.go b/lines_test.go index 1017422..f2b7f73 100644 --- a/lines_test.go +++ b/lines_test.go @@ -13,28 +13,41 @@ import ( ) func TestNewInvoiceLines(t *testing.T) { + // Set up test data invoice := &bill.Invoice{ Currency: currency.HUF, Lines: []*bill.Line{ { - Index: 1, + Index: 1, + Quantity: num.MakeAmount(2, 0), Item: &org.Item{ + Name: "Test Product", + Key: "PRODUCT", + Price: num.MakeAmount(10000, 2), + Unit: org.UnitPiece, Identities: []*org.Identity{ - {Type: "VTSZ", Code: cbc.Code("12345")}, + {Type: "VTSZ", Code: cbc.Code("1234")}, }, - Unit: org.UnitKilogram, - Key: "PRODUCT", }, - Total: num.AmountFromFloat64(1000, 2), + Sum: num.MakeAmount(20000, 2), Taxes: tax.Set{ {Category: tax.CategoryVAT, Percent: num.NewPercentage(27, 4)}, }, + Discounts: []*bill.LineDiscount{ + { + Reason: "Seasonal Discount", + Amount: num.MakeAmount(500, 2), + }, + }, + Total: num.MakeAmount(19500, 2), }, }, - Tax: &bill.Tax{Tags: []cbc.Key{tax.TagSimplified}}, } + // Execute the function under test invoiceLines, err := NewInvoiceLines(invoice) + + // Assertions assert.NoError(t, err) assert.NotNil(t, invoiceLines) assert.False(t, invoiceLines.MergedItemIndicator) @@ -42,108 +55,68 @@ func TestNewInvoiceLines(t *testing.T) { line := invoiceLines.Lines[0] assert.Equal(t, 1, line.LineNumber) - assert.NotNil(t, line.ProductCodes) - assert.Equal(t, "KILOGRAM", line.UnitOfMeasure) + assert.Equal(t, "Test Product", line.LineDescription) assert.Equal(t, "PRODUCT", line.LineNatureIndicator) - assert.NotNil(t, line.LineAmountsSimplified) - assert.Nil(t, line.LineAmountsNormal) -} + assert.Equal(t, 2.0, line.Quantity) + assert.Equal(t, 100.00, line.UnitPrice) + assert.Equal(t, "PIECE", line.UnitOfMeasure) -func TestNewLine_NormalInvoice(t *testing.T) { - line := &bill.Line{ - Index: 1, - Item: &org.Item{ - Identities: []*org.Identity{ - {Type: "VTSZ", Code: cbc.Code("12345")}, - }, - Unit: org.UnitKilogram, - Key: "PRODUCT", - }, - Total: num.AmountFromFloat64(1000, 2), - Taxes: tax.Set{ - {Category: tax.CategoryVAT, Percent: num.NewPercentage(27, 4)}, - }, - } - - taxInfo := &taxInfo{} - rate := 1.0 - - lineNav, err := NewLine(line, taxInfo, rate) - assert.NoError(t, err) - assert.NotNil(t, lineNav) - assert.Equal(t, 1, lineNav.LineNumber) - assert.Equal(t, "KILOGRAM", lineNav.UnitOfMeasure) - assert.Equal(t, "PRODUCT", lineNav.LineNatureIndicator) - assert.NotNil(t, lineNav.LineAmountsNormal) - assert.Nil(t, lineNav.LineAmountsSimplified) + // Check Product Codes + assert.NotNil(t, line.ProductCodes) + assert.Len(t, line.ProductCodes.ProductCode, 1) + assert.Equal(t, "VTSZ", line.ProductCodes.ProductCode[0].ProductCodeCategory) + assert.Equal(t, "1234", line.ProductCodes.ProductCode[0].ProductCodeValue) + + // Check Discount Data + assert.NotNil(t, line.LineDiscountData) + assert.Equal(t, "Seasonal Discount. ", line.LineDiscountData.DiscountDescription) + assert.Equal(t, 5.00, line.LineDiscountData.DiscountValue) + + // Check VAT and Amounts + assert.NotNil(t, line.LineAmountsNormal) + assert.Equal(t, 195.00, line.LineAmountsNormal.LineNetAmountData.LineNetAmount) + assert.Equal(t, 247.65, line.LineAmountsNormal.LineGrossAmountData.LineGrossAmount) // Assuming 27% VAT } func TestNewLine_SimplifiedInvoice(t *testing.T) { - line := &bill.Line{ - Index: 1, - Item: &org.Item{ - Identities: []*org.Identity{ - {Type: "VTSZ", Code: cbc.Code("12345")}, + // Set up test data for a simplified invoice + invoice := &bill.Invoice{ + Lines: []*bill.Line{ + { + Index: 1, + Item: &org.Item{ + Name: "Simplified Service", + Key: "SERVICE", + Price: num.MakeAmount(10000, 2), + Unit: org.UnitHour, + }, + Quantity: num.MakeAmount(3, 0), + Taxes: tax.Set{ + {Category: tax.CategoryVAT, Percent: num.NewPercentage(18, 4)}, + }, + Total: num.MakeAmount(30000, 2), }, - Unit: org.UnitKilogram, - Key: "PRODUCT", - }, - Total: num.AmountFromFloat64(1000, 2), - Taxes: tax.Set{ - {Category: tax.CategoryVAT, Percent: num.NewPercentage(27, 4)}, }, } - taxInfo := &taxInfo{simplifiedInvoice: true} + info := &taxInfo{simplifiedInvoice: true} rate := 1.0 - lineNav, err := NewLine(line, taxInfo, rate) - assert.NoError(t, err) - assert.NotNil(t, lineNav) - assert.Equal(t, 1, lineNav.LineNumber) - assert.Equal(t, "KILOGRAM", lineNav.UnitOfMeasure) - assert.Equal(t, "PRODUCT", lineNav.LineNatureIndicator) - assert.NotNil(t, lineNav.LineAmountsSimplified) - assert.Nil(t, lineNav.LineAmountsNormal) -} - -func TestNewProductCodes(t *testing.T) { - identities := []*org.Identity{ - {Type: "VTSZ", Code: cbc.Code("12345")}, - {Type: "OWN", Code: cbc.Code("OWN123")}, - } + // Execute the function under test + line, err := NewLine(invoice.Lines[0], info, rate) - productCodes := NewProductCodes(identities) - assert.NotNil(t, productCodes) - assert.Len(t, productCodes.ProductCode, 2) - - assert.Equal(t, "VTSZ", productCodes.ProductCode[0].ProductCodeCategory) - assert.Equal(t, "12345", productCodes.ProductCode[0].ProductCodeValue) - - assert.Equal(t, "OWN", productCodes.ProductCode[1].ProductCodeCategory) - assert.Equal(t, "OWN123", productCodes.ProductCode[1].ProductCodeOwnValue) -} - -func TestNewProductCode(t *testing.T) { - identity := &org.Identity{Type: "VTSZ", Code: cbc.Code("12345")} - productCode := NewProductCode(identity) - assert.NotNil(t, productCode) - assert.Equal(t, "VTSZ", productCode.ProductCodeCategory) - assert.Equal(t, "12345", productCode.ProductCodeValue) - - identity = &org.Identity{Type: "OWN", Code: cbc.Code("OWN123")} - productCode = NewProductCode(identity) - assert.NotNil(t, productCode) - assert.Equal(t, "OWN", productCode.ProductCodeCategory) - assert.Equal(t, "OWN123", productCode.ProductCodeOwnValue) -} - -func TestAmountToHUF(t *testing.T) { - amount := num.AmountFromFloat64(1000, 2) - exchangeRate := 300.0 + // Assertions + assert.NoError(t, err) + assert.NotNil(t, line) - convertedAmount := amountToHUF(amount, exchangeRate) - expectedAmount := num.AmountFromFloat64(300000, 2) + // Check line data for a simplified invoice + assert.Equal(t, "Simplified Service", line.LineDescription) + assert.Equal(t, "SERVICE", line.LineNatureIndicator) + assert.Equal(t, 3.0, line.Quantity) + assert.Equal(t, 100.00, line.UnitPrice) + assert.Equal(t, "HOUR", line.UnitOfMeasure) - assert.Equal(t, expectedAmount, convertedAmount) + // Check VAT and Amounts + assert.NotNil(t, line.LineAmountsSimplified) + assert.Equal(t, 300.00, line.LineAmountsSimplified.LineGrossAmountSimplified) } diff --git a/summary.go b/summary.go index 9044f28..d231911 100644 --- a/summary.go +++ b/summary.go @@ -2,10 +2,9 @@ package nav // Depends wether the invoice is simplified or not type InvoiceSummary struct { - SummaryNormal *SummaryNormal `xml:"summaryNormal"` - // This is to differentiate between normal or simplified invoice, for the moment we are only doing normal - //SummarySimplified SummarySimplified `xml:"summarySimplified,omitempty"` - //SummaryGrossData SummaryGrossData `xml:"summaryGrossData,omitempty"` + SummaryNormal *SummaryNormal `xml:"summaryNormal,omitempty"` + //SummarySimplified *SummarySimplified `xml:"summarySimplified,omitempty"` + //SummaryGrossData *SummaryGrossData `xml:"summaryGrossData,omitempty"` } type SummaryNormal struct { From 8fc104dd75512d8623d7c40b86542c1a6d42019f Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Mon, 26 Aug 2024 13:54:37 +0000 Subject: [PATCH 12/27] requestToken implemented --- .gitignore | 4 +- README.md | 3 +- go.mod | 6 +- go.sum | 75 +++++- address.go => internal/doc/address.go | 2 +- .../doc/address_test.go | 2 +- customer.go => internal/doc/customer.go | 2 +- .../doc/customer_test.go | 2 +- detail.go => internal/doc/detail.go | 2 +- detail_test.go => internal/doc/detail_test.go | 2 +- internal/doc/doc.go | 67 ++++++ head.go => internal/doc/head.go | 2 +- .../doc/invoice-valid.json | 0 invoice.go => internal/doc/invoice.go | 2 +- lines.go => internal/doc/lines.go | 2 +- lines_test.go => internal/doc/lines_test.go | 2 +- summary.go => internal/doc/summary.go | 2 +- supplier.go => internal/doc/supplier.go | 2 +- .../doc/supplier_test.go | 2 +- taxnumber.go => internal/doc/taxnumber.go | 2 +- .../doc/taxnumber_test.go | 2 +- vatrate.go => internal/doc/vatrate.go | 2 +- .../doc/vatrate_test.go | 2 +- internal/gateways/gateways.go | 1 + internal/gateways/token.go | 215 ++++++++++++++++++ internal/gateways/token_test.go | 45 ++++ nav.go | 88 ------- 27 files changed, 425 insertions(+), 113 deletions(-) rename address.go => internal/doc/address.go (99%) rename address_test.go => internal/doc/address_test.go (99%) rename customer.go => internal/doc/customer.go (99%) rename customer_test.go => internal/doc/customer_test.go (99%) rename detail.go => internal/doc/detail.go (99%) rename detail_test.go => internal/doc/detail_test.go (99%) create mode 100644 internal/doc/doc.go rename head.go => internal/doc/head.go (98%) rename invoice-valid.json => internal/doc/invoice-valid.json (100%) rename invoice.go => internal/doc/invoice.go (98%) rename lines.go => internal/doc/lines.go (99%) rename lines_test.go => internal/doc/lines_test.go (99%) rename summary.go => internal/doc/summary.go (99%) rename supplier.go => internal/doc/supplier.go (99%) rename supplier_test.go => internal/doc/supplier_test.go (99%) rename taxnumber.go => internal/doc/taxnumber.go (99%) rename taxnumber_test.go => internal/doc/taxnumber_test.go (99%) rename vatrate.go => internal/doc/vatrate.go (99%) rename vatrate_test.go => internal/doc/vatrate_test.go (99%) create mode 100644 internal/gateways/gateways.go create mode 100644 internal/gateways/token.go create mode 100644 internal/gateways/token_test.go diff --git a/.gitignore b/.gitignore index 570bb80..50caa24 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ go.work go.work.sum -/prueba \ No newline at end of file +/prueba + +.env \ No newline at end of file diff --git a/README.md b/README.md index d4c86fd..90faaa7 100644 --- a/README.md +++ b/README.md @@ -10,5 +10,4 @@ The invoice data content of the data report must be embedded, encoded in BASE64 - We don't support fiscal representatives - We don't support aggregate invoices - In the VAT rate we are missing the vat amount mismatch field (used when VAT has been charged under section 11 or 14) -- We don't support refund product charges (Field Product Fee Summary in the Invoice) -- For the moment we don't support product discounts \ No newline at end of file +- We don't support refund product charges (Field Product Fee Summary in the Invoice) \ No newline at end of file diff --git a/go.mod b/go.mod index 87c8731..b7bad81 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,11 @@ module github.com/invopop/gobl.hu-nav go 1.22.3 require ( + github.com/go-resty/resty/v2 v2.14.0 github.com/invopop/gobl v0.113.0 + github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.25.0 ) require ( @@ -21,6 +24,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/sys v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b138f9f..15c3899 100644 --- a/go.sum +++ b/go.sum @@ -11,9 +11,11 @@ github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx2 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-resty/resty/v2 v2.14.0 h1:/rhkzsAqGQkozwfKS5aFAbb6TyKd3zyFRWcdRXLPCAU= +github.com/go-resty/resty/v2 v2.14.0/go.mod h1:IW6mekUOsElt9C7oWr0XRt9BNSD6D5rr9mhk6NjmNHg= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/invopop/gobl v0.113.0 h1:Ap3Kq5bEkNWlVt8zsn2dkA4dhU5skCoMkOTfTHA33DA= @@ -24,6 +26,8 @@ github.com/invopop/validation v0.7.0 h1:NBPLqvYGmLZLQuk5jh0PbaBBetJW7f2VEk/BTWJk github.com/invopop/validation v0.7.0/go.mod h1:nLLeXYPGwUNfdCdJo7/q3yaHO62LSx/3ri7JvgKR9vg= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= @@ -39,14 +43,77 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/address.go b/internal/doc/address.go similarity index 99% rename from address.go rename to internal/doc/address.go index ef19c80..e13180c 100644 --- a/address.go +++ b/internal/doc/address.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "fmt" diff --git a/address_test.go b/internal/doc/address_test.go similarity index 99% rename from address_test.go rename to internal/doc/address_test.go index 42b9216..7da5623 100644 --- a/address_test.go +++ b/internal/doc/address_test.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "testing" diff --git a/customer.go b/internal/doc/customer.go similarity index 99% rename from customer.go rename to internal/doc/customer.go index fdc05bb..c01c80a 100644 --- a/customer.go +++ b/internal/doc/customer.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "github.com/invopop/gobl/l10n" diff --git a/customer_test.go b/internal/doc/customer_test.go similarity index 99% rename from customer_test.go rename to internal/doc/customer_test.go index 26d5466..de40486 100644 --- a/customer_test.go +++ b/internal/doc/customer_test.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "reflect" diff --git a/detail.go b/internal/doc/detail.go similarity index 99% rename from detail.go rename to internal/doc/detail.go index 2bbb415..e62d47c 100644 --- a/detail.go +++ b/internal/doc/detail.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "github.com/invopop/gobl/bill" diff --git a/detail_test.go b/internal/doc/detail_test.go similarity index 99% rename from detail_test.go rename to internal/doc/detail_test.go index a730f53..07d8644 100644 --- a/detail_test.go +++ b/internal/doc/detail_test.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "testing" diff --git a/internal/doc/doc.go b/internal/doc/doc.go new file mode 100644 index 0000000..8fc5752 --- /dev/null +++ b/internal/doc/doc.go @@ -0,0 +1,67 @@ +package doc + +import ( + "encoding/xml" + "errors" + + "github.com/invopop/gobl/bill" +) + +/* +*/ + +// Standard error responses. +var ( + ErrNotHungarian = newValidationError("only hungarian invoices are supported") + ErrNoExchangeRate = newValidationError("no exchange rate to HUF found") + ErrInvalidGroupMemberCode = newValidationError("invalid group member code") + ErrNoVatRateField = newValidationError("no vat rate field found") +) + +// ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed +// to server-side errors (that should be retried). +type ValidationError struct { + err error +} + +// Error implements the error interface for ClientError. +func (e *ValidationError) Error() string { + return e.err.Error() +} + +func newValidationError(text string) error { + return &ValidationError{errors.New(text)} +} + +type Document struct { + XMLName xml.Name `xml:"InvoiceData"` + XMLNS string `xml:"xmlns,attr"` + //XMLNSXsi string `xml:"xmlns:xsi,attr"` + //XSISchema string `xml:"xsi:schemaLocation,attr"` + //XMLNSCommon string `xml:"xmlns:common,attr"` + //XMLNSBase string `xml:"xmlns:base,attr"` + InvoiceNumber string `xml:"invoiceNumber"` + InvoiceIssueDate string `xml:"invoiceIssueDate"` + CompletenessIndicator bool `xml:"completenessIndicator"` // Indicates whether the data report is the invoice itself + InvoiceMain *InvoiceMain `xml:"invoiceMain"` +} + +// Convert it to XML before returning +func NewDocument(inv *bill.Invoice) *Document { + d := new(Document) + d.XMLNS = "http://schemas.nav.gov.hu/OSA/3.0/data" + //d.XMLNSXsi = "http://www.w3.org/2001/XMLSchema-instance" + //d.XSISchema = "http://schemas.nav.gov.hu/OSA/3.0/data invoiceData.xsd" + //d.XMLNSCommon = "http://schemas.nav.gov.hu/NTCA/1.0/common" + //d.XMLNSBase = "http://schemas.nav.gov.hu/OSA/3.0/base" + d.InvoiceNumber = inv.Code + d.InvoiceIssueDate = inv.IssueDate.String() + d.CompletenessIndicator = false + main, err := NewInvoiceMain(inv) + if err != nil { + panic(err) + } + d.InvoiceMain = main + return d +} diff --git a/head.go b/internal/doc/head.go similarity index 98% rename from head.go rename to internal/doc/head.go index b9501cf..3151d3d 100644 --- a/head.go +++ b/internal/doc/head.go @@ -1,4 +1,4 @@ -package nav +package doc import "github.com/invopop/gobl/bill" diff --git a/invoice-valid.json b/internal/doc/invoice-valid.json similarity index 100% rename from invoice-valid.json rename to internal/doc/invoice-valid.json diff --git a/invoice.go b/internal/doc/invoice.go similarity index 98% rename from invoice.go rename to internal/doc/invoice.go index d9b2d1d..0ac6b1b 100644 --- a/invoice.go +++ b/internal/doc/invoice.go @@ -1,4 +1,4 @@ -package nav +package doc import "github.com/invopop/gobl/bill" diff --git a/lines.go b/internal/doc/lines.go similarity index 99% rename from lines.go rename to internal/doc/lines.go index cc89e33..0e27363 100644 --- a/lines.go +++ b/internal/doc/lines.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "github.com/invopop/gobl/bill" diff --git a/lines_test.go b/internal/doc/lines_test.go similarity index 99% rename from lines_test.go rename to internal/doc/lines_test.go index f2b7f73..d250a92 100644 --- a/lines_test.go +++ b/internal/doc/lines_test.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "testing" diff --git a/summary.go b/internal/doc/summary.go similarity index 99% rename from summary.go rename to internal/doc/summary.go index d231911..b3009e7 100644 --- a/summary.go +++ b/internal/doc/summary.go @@ -1,4 +1,4 @@ -package nav +package doc // Depends wether the invoice is simplified or not type InvoiceSummary struct { diff --git a/supplier.go b/internal/doc/supplier.go similarity index 99% rename from supplier.go rename to internal/doc/supplier.go index 7112e2d..7f2ba27 100644 --- a/supplier.go +++ b/internal/doc/supplier.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "github.com/invopop/gobl/l10n" diff --git a/supplier_test.go b/internal/doc/supplier_test.go similarity index 99% rename from supplier_test.go rename to internal/doc/supplier_test.go index 85152db..1843c0e 100644 --- a/supplier_test.go +++ b/internal/doc/supplier_test.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "testing" diff --git a/taxnumber.go b/internal/doc/taxnumber.go similarity index 99% rename from taxnumber.go rename to internal/doc/taxnumber.go index 61c61ae..ce640a7 100644 --- a/taxnumber.go +++ b/internal/doc/taxnumber.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "github.com/invopop/gobl/l10n" diff --git a/taxnumber_test.go b/internal/doc/taxnumber_test.go similarity index 99% rename from taxnumber_test.go rename to internal/doc/taxnumber_test.go index 12b41f0..4b82fa6 100644 --- a/taxnumber_test.go +++ b/internal/doc/taxnumber_test.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "reflect" diff --git a/vatrate.go b/internal/doc/vatrate.go similarity index 99% rename from vatrate.go rename to internal/doc/vatrate.go index c249f73..91ac639 100644 --- a/vatrate.go +++ b/internal/doc/vatrate.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "github.com/invopop/gobl/bill" diff --git a/vatrate_test.go b/internal/doc/vatrate_test.go similarity index 99% rename from vatrate_test.go rename to internal/doc/vatrate_test.go index 8270f47..33ecf61 100644 --- a/vatrate_test.go +++ b/internal/doc/vatrate_test.go @@ -1,4 +1,4 @@ -package nav +package doc import ( "testing" diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go new file mode 100644 index 0000000..8053d19 --- /dev/null +++ b/internal/gateways/gateways.go @@ -0,0 +1 @@ +package gateways diff --git a/internal/gateways/token.go b/internal/gateways/token.go new file mode 100644 index 0000000..0c5003a --- /dev/null +++ b/internal/gateways/token.go @@ -0,0 +1,215 @@ +package gateways + +import ( + "crypto/sha512" + "encoding/hex" + "encoding/xml" + "fmt" + "hash" + "math/rand" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/invopop/gobl/tax" + "golang.org/x/crypto/sha3" +) + +const ( + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + tokenExchangeEndpoint = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/tokenExchange" +) + +type TokenExchangeRequest struct { + XMLName xml.Name `xml:"TokenExchangeRequest"` + Common string `xml:"xmlns:common,attr"` + Xmlns string `xml:"xmlns,attr"` + Header Header `xml:"common:header"` + User User `xml:"common:user"` + Software Software `xml:"software"` +} + +type Header struct { + RequestId string `xml:"common:requestId"` + Timestamp string `xml:"common:timestamp"` + RequestVersion string `xml:"common:requestVersion"` + HeaderVersion string `xml:"common:headerVersion"` +} + +type User struct { + Login string `xml:"common:login"` + PasswordHash PasswordHash `xml:"common:passwordHash"` + TaxNumber string `xml:"common:taxNumber"` + RequestSignature RequestSignature `xml:"common:requestSignature"` +} + +type PasswordHash struct { + CryptoType string `xml:"cryptoType,attr"` + Value string `xml:",chardata"` +} + +type RequestSignature struct { + CryptoType string `xml:"cryptoType,attr"` + Value string `xml:",chardata"` +} + +type Software struct { + SoftwareId string `xml:"softwareId"` + SoftwareName string `xml:"softwareName"` + SoftwareOperation string `xml:"softwareOperation"` + SoftwareMainVersion string `xml:"softwareMainVersion"` + SoftwareDevName string `xml:"softwareDevName"` + SoftwareDevContact string `xml:"softwareDevContact"` + SoftwareDevCountryCode string `xml:"softwareDevCountryCode"` + SoftwareDevTaxNumber string `xml:"softwareDevTaxNumber"` +} + +type TokenExchangeResponse struct { + XMLName xml.Name `xml:"TokenExchangeResponse"` + Header Header `xml:"header"` + Result Result `xml:"result"` + Software Software `xml:"software"` + EncodedExchangeToken string `xml:"encodedExchangeToken"` + TokenValidityFrom string `xml:"tokenValidityFrom"` + TokenValidityTo string `xml:"tokenValidityTo"` +} + +type Result struct { + FuncCode string `xml:"funcCode"` +} + +type GeneralErrorResponse struct { + XMLName xml.Name `xml:"GeneralErrorResponse"` + Header Header `xml:"header"` + Result ErrorResult `xml:"result"` + Software Software `xml:"software"` +} + +type ErrorResult struct { + FuncCode string `xml:"funcCode"` + ErrorCode string `xml:"errorCode"` + Message string `xml:"message"` +} + +//type Client struct { +// } + +func NewTokenExchangeRequest(requestData TokenExchangeRequest) (string, error) { + client := resty.New() + + resp, err := client.R(). + SetHeader("Content-Type", "application/xml"). + SetHeader("Accept", "application/xml"). + SetBody(requestData). + Post(tokenExchangeEndpoint) + + if err != nil { + return "", err + } + + if resp.StatusCode() == 200 { + var tokenExchangeResponse TokenExchangeResponse + err = xml.Unmarshal(resp.Body(), &tokenExchangeResponse) + if err != nil { + return "", err + } + + return tokenExchangeResponse.EncodedExchangeToken, nil + } + + var generalErrorResponse GeneralErrorResponse + err = xml.Unmarshal(resp.Body(), &generalErrorResponse) + if err != nil { + return "", err + } + + return "", fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) + +} + +func NewTokenExchangeData(userName string, password string, signKey, taxNumber string, soft Software) TokenExchangeRequest { + timestamp := time.Now().UTC() + requestID := generateRandomString(20) //This cannnot be repeated in the time window of 5 mins + return TokenExchangeRequest{ + Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", + Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", + Header: Header{ + RequestId: requestID, + Timestamp: timestamp.Format("2006-01-02T15:04:05.000Z"), + RequestVersion: "3.0", + HeaderVersion: "1.0", + }, + User: User{ + Login: userName, + PasswordHash: PasswordHash{CryptoType: "SHA-512", Value: hashPassword(password)}, + TaxNumber: taxNumber, + RequestSignature: RequestSignature{ + CryptoType: "SHA3-512", + Value: computeRequestSignature(requestID, timestamp, signKey), + }, + }, + Software: soft, + } +} + +func NewSoftware(taxNumber tax.Identity, name string, operation string, version string, devName string, devContact string) Software { + + if operation != "ONLINE_SERVICE" && operation != "LOCAL_SOFTWARE" { + operation = "ONLINE_SERVICE" + } + + return Software{ + SoftwareId: NewSoftwareID(taxNumber), + SoftwareName: name, + SoftwareOperation: operation, + SoftwareMainVersion: version, + SoftwareDevName: devName, + SoftwareDevContact: devContact, + SoftwareDevCountryCode: taxNumber.Country.String(), + SoftwareDevTaxNumber: taxNumber.Code.String(), + } +} + +func NewSoftwareID(taxNumber tax.Identity) string { + // 18-length string: + //first characters are the country code and tax id + //the rest is random + + lenRandom := 18 - len(taxNumber.String()) + + return taxNumber.String() + generateRandomString(lenRandom) +} + +func generateRandomString(length int) string { + b := make([]byte, length) + for i := range b { + b[i] = charset[rand.Intn(len(charset))] + } + return string(b) +} + +func hashPassword(password string) string { + hash := sha512.New() + + return hashInput(hash, password) +} + +func computeRequestSignature(requestID string, timestamp time.Time, signKey string) string { + hash := sha3.New512() + + timeSignature := timestamp.Format("20060102150405") + + return hashInput(hash, requestID+timeSignature+signKey) +} + +func hashInput(hash hash.Hash, input string) string { + hash.Write([]byte(input)) + + hashSum := hash.Sum(nil) + + hashHex := hex.EncodeToString(hashSum) + + hashHexUpper := strings.ToUpper(hashHex) + + return hashHexUpper +} diff --git a/internal/gateways/token_test.go b/internal/gateways/token_test.go new file mode 100644 index 0000000..93b2b0b --- /dev/null +++ b/internal/gateways/token_test.go @@ -0,0 +1,45 @@ +package gateways + +import ( + "log" + "os" + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/tax" + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewTokenExchangeRequest(t *testing.T) { + // Set up test data + software := NewSoftware( + tax.Identity{Country: l10n.ES.Tax(), Code: cbc.Code("B12345678")}, + "Invopop", + "ONLINE_SERVICE", + "1.0.0", + "TestDev", + "dev@test.com", + ) + + err := godotenv.Load("../../.env") + if err != nil { + log.Fatalf("Error loading .env file") + } + + userID := os.Getenv("USER_ID") + userPWD := os.Getenv("USER_PWD") + signToken := os.Getenv("SIGN_TOKEN") + taxID := os.Getenv("TAX_ID") + + requestData := NewTokenExchangeData(userID, userPWD, signToken, taxID, software) + + // Execute the function + token, err := NewTokenExchangeRequest(requestData) + + // Assert results + require.NoError(t, err, "Expected no error from NewTokenExchangeRequest") + assert.NotEmpty(t, token, "Expected non-empty token from NewTokenExchangeRequest") +} diff --git a/nav.go b/nav.go index 9fbfb95..8b5f683 100644 --- a/nav.go +++ b/nav.go @@ -1,89 +1 @@ package nav - -import ( - "encoding/xml" - "errors" - - "github.com/invopop/gobl/bill" -) - -/* -*/ - -// Standard error responses. -var ( - ErrNotHungarian = newValidationError("only hungarian invoices are supported") - ErrNoExchangeRate = newValidationError("no exchange rate to HUF found") - ErrInvalidGroupMemberCode = newValidationError("invalid group member code") - ErrNoVatRateField = newValidationError("no vat rate field found") -) - -// ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed -// to server-side errors (that should be retried). -type ValidationError struct { - err error -} - -// Error implements the error interface for ClientError. -func (e *ValidationError) Error() string { - return e.err.Error() -} - -func newValidationError(text string) error { - return &ValidationError{errors.New(text)} -} - -type Document struct { - XMLName xml.Name `xml:"InvoiceData"` - XMLNS string `xml:"xmlns,attr"` - //XMLNSXsi string `xml:"xmlns:xsi,attr"` - //XSISchema string `xml:"xsi:schemaLocation,attr"` - //XMLNSCommon string `xml:"xmlns:common,attr"` - //XMLNSBase string `xml:"xmlns:base,attr"` - InvoiceNumber string `xml:"invoiceNumber"` - InvoiceIssueDate string `xml:"invoiceIssueDate"` - CompletenessIndicator bool `xml:"completenessIndicator"` // Indicates whether the data report is the invoice itself - InvoiceMain *InvoiceMain `xml:"invoiceMain"` -} - -// Convert it to XML before returning -func NewDocument(inv *bill.Invoice) *Document { - d := new(Document) - d.XMLNS = "http://schemas.nav.gov.hu/OSA/3.0/data" - //d.XMLNSXsi = "http://www.w3.org/2001/XMLSchema-instance" - //d.XSISchema = "http://schemas.nav.gov.hu/OSA/3.0/data invoiceData.xsd" - //d.XMLNSCommon = "http://schemas.nav.gov.hu/NTCA/1.0/common" - //d.XMLNSBase = "http://schemas.nav.gov.hu/OSA/3.0/base" - d.InvoiceNumber = inv.Code - d.InvoiceIssueDate = inv.IssueDate.String() - d.CompletenessIndicator = false - main, err := NewInvoiceMain(inv) - if err != nil { - panic(err) - } - d.InvoiceMain = main - return d -} - -/*func main() { - data, _ := os.ReadFile("invoice-valid.json") - fmt.Println(string(data)) - env := new(gobl.Envelope) - if err := json.Unmarshal(data, env); err != nil { - panic(err) - } - - inv, ok := env.Extract().(*bill.Invoice) - if !ok { - fmt.Errorf("invalid type %T", env.Document) - } - - doc := NewDocument(inv) - // Print the XML - output, err := xml.MarshalIndent(doc, "", " ") - if err != nil { - panic(err) - } - fmt.Println(string(output)) -}*/ From 665d4637de745ecc2b16cc23f40b33c8d53fd0a9 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Mon, 26 Aug 2024 17:04:22 +0000 Subject: [PATCH 13/27] manageInvoice implementation --- internal/gateways/common.go | 149 ++++++++++++ internal/gateways/gateways.go | 15 ++ internal/gateways/manageInvoice.go | 89 ++++++++ internal/gateways/token.go | 215 ------------------ internal/gateways/tokenExchange.go | 137 +++++++++++ .../{token_test.go => tokenExchange_test.go} | 4 +- 6 files changed, 392 insertions(+), 217 deletions(-) create mode 100644 internal/gateways/common.go create mode 100644 internal/gateways/manageInvoice.go delete mode 100644 internal/gateways/token.go create mode 100644 internal/gateways/tokenExchange.go rename internal/gateways/{token_test.go => tokenExchange_test.go} (87%) diff --git a/internal/gateways/common.go b/internal/gateways/common.go new file mode 100644 index 0000000..953606e --- /dev/null +++ b/internal/gateways/common.go @@ -0,0 +1,149 @@ +package gateways + +import ( + "crypto/sha512" + "encoding/hex" + "hash" + "math/rand" + "strings" + "time" + + "github.com/invopop/gobl/tax" + "golang.org/x/crypto/sha3" +) + +type Header struct { + RequestId string `xml:"common:requestId"` + Timestamp string `xml:"common:timestamp"` + RequestVersion string `xml:"common:requestVersion"` + HeaderVersion string `xml:"common:headerVersion"` +} + +type User struct { + Login string `xml:"common:login"` + PasswordHash PasswordHash `xml:"common:passwordHash"` + TaxNumber string `xml:"common:taxNumber"` + RequestSignature RequestSignature `xml:"common:requestSignature"` +} + +type PasswordHash struct { + CryptoType string `xml:"cryptoType,attr"` + Value string `xml:",chardata"` +} + +type RequestSignature struct { + CryptoType string `xml:"cryptoType,attr"` + Value string `xml:",chardata"` +} + +type Software struct { + SoftwareId string `xml:"softwareId"` + SoftwareName string `xml:"softwareName"` + SoftwareOperation string `xml:"softwareOperation"` + SoftwareMainVersion string `xml:"softwareMainVersion"` + SoftwareDevName string `xml:"softwareDevName"` + SoftwareDevContact string `xml:"softwareDevContact"` + SoftwareDevCountryCode string `xml:"softwareDevCountryCode"` + SoftwareDevTaxNumber string `xml:"softwareDevTaxNumber"` +} + +func NewHeader(requestID string, timestamp time.Time) *Header { + return &Header{ + RequestId: requestID, + Timestamp: timestamp.Format("2006-01-02T15:04:05.000Z"), + RequestVersion: "3.0", + HeaderVersion: "1.0", + } +} + +func NewUser(userName string, password string, taxNumber string, signKey string, requestID string, timestamp time.Time, options ...string) *User { + signature := "" + if len(options) > 0 { + base := options[0] + signature = computeRequestSignature(requestID, timestamp, signKey, base) + } else { + signature = computeRequestSignature(requestID, timestamp, signKey) + } + return &User{ + Login: userName, + PasswordHash: PasswordHash{CryptoType: "SHA-512", Value: hashPassword(password)}, + TaxNumber: taxNumber, + RequestSignature: RequestSignature{ + CryptoType: "SHA3-512", + Value: signature, + }, + } +} + +func hashPassword(password string) string { + hash := sha512.New() + + return hashInput(hash, password) +} + +func computeRequestSignature(requestID string, timestamp time.Time, signKey string, options ...string) string { + hash := sha3.New512() + + timeSignature := timestamp.Format("20060102150405") + + hashBase := requestID + timeSignature + signKey + + if len(options) == 0 { + return hashInput(hash, hashBase) + } + + hashedInvoice := hashInput(hash, options[0]) + + hashBase += hashedInvoice + + return hashInput(hash, hashBase) + +} + +func NewSoftware(taxNumber tax.Identity, name string, operation string, version string, devName string, devContact string) *Software { + + if operation != "ONLINE_SERVICE" && operation != "LOCAL_SOFTWARE" { + operation = "ONLINE_SERVICE" + } + + return &Software{ + SoftwareId: NewSoftwareID(taxNumber), + SoftwareName: name, + SoftwareOperation: operation, + SoftwareMainVersion: version, + SoftwareDevName: devName, + SoftwareDevContact: devContact, + SoftwareDevCountryCode: taxNumber.Country.String(), + SoftwareDevTaxNumber: taxNumber.Code.String(), + } +} + +func NewSoftwareID(taxNumber tax.Identity) string { + // 18-length string: + //first characters are the country code and tax id + //the rest is random + + lenRandom := 18 - len(taxNumber.String()) + + return taxNumber.String() + generateRandomString(lenRandom) +} + +func generateRandomString(length int) string { + b := make([]byte, length) + for i := range b { + b[i] = charset[rand.Intn(len(charset))] + } + return string(b) +} + +func hashInput(hash hash.Hash, input string) string { + hash.Write([]byte(input)) + + hashSum := hash.Sum(nil) + + hashHex := hex.EncodeToString(hashSum) + + hashHexUpper := strings.ToUpper(hashHex) + + return hashHexUpper +} diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index 8053d19..862a9e5 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -1 +1,16 @@ package gateways + +import ( + "time" + + cmap "github.com/orcaman/concurrent-map/v2" +) + +type gateways struct { + tokenCache cmap.ConcurrentMap[string, TokenInfo] +} + +type TokenInfo struct { + Token string + Expiration time.Time +} diff --git a/internal/gateways/manageInvoice.go b/internal/gateways/manageInvoice.go new file mode 100644 index 0000000..1d9ae14 --- /dev/null +++ b/internal/gateways/manageInvoice.go @@ -0,0 +1,89 @@ +package gateways + +import ( + "encoding/xml" + "fmt" + "time" + + "github.com/go-resty/resty/v2" +) + +const ( + manageInvoiceEndpoint = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/manageInvoice" +) + +// ManageInvoiceRequest represents the root element +type ManageInvoiceRequest struct { + XMLName xml.Name `xml:"http://schemas.nav.gov.hu/OSA/3.0/api ManageInvoiceRequest"` + Common string `xml:"xmlns:common,attr"` + Xmlns string `xml:"xmlns,attr"` + Header *Header `xml:"common:header"` + User *User `xml:"common:user"` + Software *Software `xml:"software"` + ExchangeToken string `xml:"exchangeToken"` + InvoiceOperations *InvoiceOperations `xml:"invoiceOperations"` +} + +// InvoiceOperations represents the invoiceOperations element +type InvoiceOperations struct { + CompressedContent bool `xml:"compressedContent"` + InvoiceOperation []*InvoiceOperation `xml:"invoiceOperation"` +} + +// InvoiceOperation represents the invoiceOperation element +type InvoiceOperation struct { + Index int `xml:"index"` + InvoiceOperationType string `xml:"invoiceOperation"` + InvoiceData string `xml:"invoiceData"` + ElectronicInvoiceHash *ElectronicInvoiceHash `xml:"electronicInvoiceHash,omitempty"` +} + +// ElectronicInvoiceHash represents the electronicInvoiceHash element +type ElectronicInvoiceHash struct { + CryptoType string `xml:"cryptoType,attr"` + Value string `xml:",chardata"` +} + +func NewManageInvoiceRequest(username string, password string, taxNumber string, signKey string, exchangeToken string, soft *Software, invoice string) ManageInvoiceRequest { + timestamp := time.Now().UTC() + requestID := generateRandomString(20) //This cannnot be repeated in the time window of 5 mins + operationType := "CREATE" + return ManageInvoiceRequest{ + Common: "http://schemas.nav.gov.hu/OSA/3.0/common", + Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", + Header: NewHeader(requestID, timestamp), + User: NewUser(username, password, taxNumber, signKey, requestID, timestamp, operationType+invoice), + Software: soft, + ExchangeToken: exchangeToken, + InvoiceOperations: &InvoiceOperations{ + CompressedContent: false, + InvoiceOperation: []*InvoiceOperation{ + { + Index: 1, + InvoiceOperationType: operationType, + InvoiceData: invoice, + }, + }, + }, + } +} + +func PostManageInvoiceRequest(requestData ManageInvoiceRequest) (string, error) { + client := resty.New() + + resp, err := client.R(). + SetHeader("Content-Type", "application/xml"). + SetHeader("Accept", "application/xml"). + SetBody(requestData). + Post(manageInvoiceEndpoint) + + if err != nil { + return "", err + } + + if resp.StatusCode() == 200 { + return resp.String(), nil + } + + return "", fmt.Errorf("error code: %s", resp.Status()) +} diff --git a/internal/gateways/token.go b/internal/gateways/token.go deleted file mode 100644 index 0c5003a..0000000 --- a/internal/gateways/token.go +++ /dev/null @@ -1,215 +0,0 @@ -package gateways - -import ( - "crypto/sha512" - "encoding/hex" - "encoding/xml" - "fmt" - "hash" - "math/rand" - "strings" - "time" - - "github.com/go-resty/resty/v2" - "github.com/invopop/gobl/tax" - "golang.org/x/crypto/sha3" -) - -const ( - charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - tokenExchangeEndpoint = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/tokenExchange" -) - -type TokenExchangeRequest struct { - XMLName xml.Name `xml:"TokenExchangeRequest"` - Common string `xml:"xmlns:common,attr"` - Xmlns string `xml:"xmlns,attr"` - Header Header `xml:"common:header"` - User User `xml:"common:user"` - Software Software `xml:"software"` -} - -type Header struct { - RequestId string `xml:"common:requestId"` - Timestamp string `xml:"common:timestamp"` - RequestVersion string `xml:"common:requestVersion"` - HeaderVersion string `xml:"common:headerVersion"` -} - -type User struct { - Login string `xml:"common:login"` - PasswordHash PasswordHash `xml:"common:passwordHash"` - TaxNumber string `xml:"common:taxNumber"` - RequestSignature RequestSignature `xml:"common:requestSignature"` -} - -type PasswordHash struct { - CryptoType string `xml:"cryptoType,attr"` - Value string `xml:",chardata"` -} - -type RequestSignature struct { - CryptoType string `xml:"cryptoType,attr"` - Value string `xml:",chardata"` -} - -type Software struct { - SoftwareId string `xml:"softwareId"` - SoftwareName string `xml:"softwareName"` - SoftwareOperation string `xml:"softwareOperation"` - SoftwareMainVersion string `xml:"softwareMainVersion"` - SoftwareDevName string `xml:"softwareDevName"` - SoftwareDevContact string `xml:"softwareDevContact"` - SoftwareDevCountryCode string `xml:"softwareDevCountryCode"` - SoftwareDevTaxNumber string `xml:"softwareDevTaxNumber"` -} - -type TokenExchangeResponse struct { - XMLName xml.Name `xml:"TokenExchangeResponse"` - Header Header `xml:"header"` - Result Result `xml:"result"` - Software Software `xml:"software"` - EncodedExchangeToken string `xml:"encodedExchangeToken"` - TokenValidityFrom string `xml:"tokenValidityFrom"` - TokenValidityTo string `xml:"tokenValidityTo"` -} - -type Result struct { - FuncCode string `xml:"funcCode"` -} - -type GeneralErrorResponse struct { - XMLName xml.Name `xml:"GeneralErrorResponse"` - Header Header `xml:"header"` - Result ErrorResult `xml:"result"` - Software Software `xml:"software"` -} - -type ErrorResult struct { - FuncCode string `xml:"funcCode"` - ErrorCode string `xml:"errorCode"` - Message string `xml:"message"` -} - -//type Client struct { -// } - -func NewTokenExchangeRequest(requestData TokenExchangeRequest) (string, error) { - client := resty.New() - - resp, err := client.R(). - SetHeader("Content-Type", "application/xml"). - SetHeader("Accept", "application/xml"). - SetBody(requestData). - Post(tokenExchangeEndpoint) - - if err != nil { - return "", err - } - - if resp.StatusCode() == 200 { - var tokenExchangeResponse TokenExchangeResponse - err = xml.Unmarshal(resp.Body(), &tokenExchangeResponse) - if err != nil { - return "", err - } - - return tokenExchangeResponse.EncodedExchangeToken, nil - } - - var generalErrorResponse GeneralErrorResponse - err = xml.Unmarshal(resp.Body(), &generalErrorResponse) - if err != nil { - return "", err - } - - return "", fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) - -} - -func NewTokenExchangeData(userName string, password string, signKey, taxNumber string, soft Software) TokenExchangeRequest { - timestamp := time.Now().UTC() - requestID := generateRandomString(20) //This cannnot be repeated in the time window of 5 mins - return TokenExchangeRequest{ - Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", - Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", - Header: Header{ - RequestId: requestID, - Timestamp: timestamp.Format("2006-01-02T15:04:05.000Z"), - RequestVersion: "3.0", - HeaderVersion: "1.0", - }, - User: User{ - Login: userName, - PasswordHash: PasswordHash{CryptoType: "SHA-512", Value: hashPassword(password)}, - TaxNumber: taxNumber, - RequestSignature: RequestSignature{ - CryptoType: "SHA3-512", - Value: computeRequestSignature(requestID, timestamp, signKey), - }, - }, - Software: soft, - } -} - -func NewSoftware(taxNumber tax.Identity, name string, operation string, version string, devName string, devContact string) Software { - - if operation != "ONLINE_SERVICE" && operation != "LOCAL_SOFTWARE" { - operation = "ONLINE_SERVICE" - } - - return Software{ - SoftwareId: NewSoftwareID(taxNumber), - SoftwareName: name, - SoftwareOperation: operation, - SoftwareMainVersion: version, - SoftwareDevName: devName, - SoftwareDevContact: devContact, - SoftwareDevCountryCode: taxNumber.Country.String(), - SoftwareDevTaxNumber: taxNumber.Code.String(), - } -} - -func NewSoftwareID(taxNumber tax.Identity) string { - // 18-length string: - //first characters are the country code and tax id - //the rest is random - - lenRandom := 18 - len(taxNumber.String()) - - return taxNumber.String() + generateRandomString(lenRandom) -} - -func generateRandomString(length int) string { - b := make([]byte, length) - for i := range b { - b[i] = charset[rand.Intn(len(charset))] - } - return string(b) -} - -func hashPassword(password string) string { - hash := sha512.New() - - return hashInput(hash, password) -} - -func computeRequestSignature(requestID string, timestamp time.Time, signKey string) string { - hash := sha3.New512() - - timeSignature := timestamp.Format("20060102150405") - - return hashInput(hash, requestID+timeSignature+signKey) -} - -func hashInput(hash hash.Hash, input string) string { - hash.Write([]byte(input)) - - hashSum := hash.Sum(nil) - - hashHex := hex.EncodeToString(hashSum) - - hashHexUpper := strings.ToUpper(hashHex) - - return hashHexUpper -} diff --git a/internal/gateways/tokenExchange.go b/internal/gateways/tokenExchange.go new file mode 100644 index 0000000..5cdbf02 --- /dev/null +++ b/internal/gateways/tokenExchange.go @@ -0,0 +1,137 @@ +package gateways + +import ( + "crypto/aes" + "encoding/base64" + "encoding/xml" + "fmt" + "time" + + "github.com/go-resty/resty/v2" +) + +const ( + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + tokenExchangeEndpoint = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/tokenExchange" +) + +type TokenExchangeRequest struct { + XMLName xml.Name `xml:"TokenExchangeRequest"` + Common string `xml:"xmlns:common,attr"` + Xmlns string `xml:"xmlns,attr"` + Header *Header `xml:"common:header"` + User *User `xml:"common:user"` + Software *Software `xml:"software"` +} + +type TokenExchangeResponse struct { + XMLName xml.Name `xml:"TokenExchangeResponse"` + Header *Header `xml:"header"` + Result *Result `xml:"result"` + Software *Software `xml:"software"` + EncodedExchangeToken string `xml:"encodedExchangeToken"` + TokenValidityFrom string `xml:"tokenValidityFrom"` + TokenValidityTo string `xml:"tokenValidityTo"` +} + +type Result struct { + FuncCode string `xml:"funcCode"` +} + +type GeneralErrorResponse struct { + XMLName xml.Name `xml:"GeneralErrorResponse"` + Header *Header `xml:"header"` + Result *ErrorResult `xml:"result"` + Software *Software `xml:"software"` +} + +type ErrorResult struct { + FuncCode string `xml:"funcCode"` + ErrorCode string `xml:"errorCode"` + Message string `xml:"message"` +} + +//type Client struct { +// } + +func PostTokenExchangeRequest(requestData TokenExchangeRequest) (string, error) { + client := resty.New() + + resp, err := client.R(). + SetHeader("Content-Type", "application/xml"). + SetHeader("Accept", "application/xml"). + SetBody(requestData). + Post(tokenExchangeEndpoint) + + if err != nil { + return "", err + } + + if resp.StatusCode() == 200 { + var tokenExchangeResponse TokenExchangeResponse + err = xml.Unmarshal(resp.Body(), &tokenExchangeResponse) + if err != nil { + return "", err + } + + return tokenExchangeResponse.EncodedExchangeToken, nil + } + + var generalErrorResponse GeneralErrorResponse + err = xml.Unmarshal(resp.Body(), &generalErrorResponse) + if err != nil { + return "", err + } + + return "", fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) + +} + +func NewTokenExchangeRequest(userName string, password string, signKey string, taxNumber string, soft *Software) TokenExchangeRequest { + timestamp := time.Now().UTC() + requestID := generateRandomString(20) //This cannnot be repeated in the time window of 5 mins + return TokenExchangeRequest{ + Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", + Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", + Header: NewHeader(requestID, timestamp), + User: NewUser(userName, password, taxNumber, signKey, requestID, timestamp), + Software: soft, + } +} + +func decryptToken(encodedToken string, keyString string) (string, error) { + key := []byte(keyString) + + // Decode the base64 encoded encrypted key + ciphertext, err := base64.StdEncoding.DecodeString(encodedToken) + if err != nil { + return "", err + } + + // Create a new AES cipher block + block, err := aes.NewCipher(key) + if err != nil { + fmt.Println("Error creating cipher:", err) + return "", err + } + + // Create a buffer for the decrypted text + decrypted := make([]byte, len(ciphertext)) + + // Decrypt the ciphertext + for bs, be := 0, block.BlockSize(); bs < len(ciphertext); bs, be = bs+block.BlockSize(), be+block.BlockSize() { + block.Decrypt(decrypted[bs:be], ciphertext[bs:be]) + } + + // Remove padding (if any) + decrypted = unpad(decrypted) + + return string(decrypted), nil +} + +// PKCS7 unpadding +func unpad(data []byte) []byte { + length := len(data) + unpadding := int(data[length-1]) + return data[:(length - unpadding)] +} diff --git a/internal/gateways/token_test.go b/internal/gateways/tokenExchange_test.go similarity index 87% rename from internal/gateways/token_test.go rename to internal/gateways/tokenExchange_test.go index 93b2b0b..04c4cef 100644 --- a/internal/gateways/token_test.go +++ b/internal/gateways/tokenExchange_test.go @@ -34,10 +34,10 @@ func TestNewTokenExchangeRequest(t *testing.T) { signToken := os.Getenv("SIGN_TOKEN") taxID := os.Getenv("TAX_ID") - requestData := NewTokenExchangeData(userID, userPWD, signToken, taxID, software) + requestData := NewTokenExchangeRequest(userID, userPWD, signToken, taxID, software) // Execute the function - token, err := NewTokenExchangeRequest(requestData) + token, err := PostTokenExchangeRequest(requestData) // Assert results require.NoError(t, err, "Expected no error from NewTokenExchangeRequest") From 3f138d9c62e7889b984d839186532a4f5c47ea2a Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Tue, 27 Aug 2024 11:52:43 +0000 Subject: [PATCH 14/27] Reporting invoice done --- README.md | 6 +- examples/example.xml | 97 +++++++++++++++++++++++++ examples/example1.xml | 97 +++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + internal/gateways/common.go | 28 +++++-- internal/gateways/gateways.go | 16 ---- internal/gateways/manageInvoice.go | 38 ++++++++-- internal/gateways/tokenExchange.go | 72 +++++++++++------- internal/gateways/tokenExchange_test.go | 11 ++- nav.go | 48 ++++++++++++ nav_test.go | 56 ++++++++++++++ 12 files changed, 408 insertions(+), 64 deletions(-) create mode 100644 examples/example.xml create mode 100644 examples/example1.xml delete mode 100644 internal/gateways/gateways.go create mode 100644 nav_test.go diff --git a/README.md b/README.md index 90faaa7..546650b 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,8 @@ The invoice data content of the data report must be embedded, encoded in BASE64 - We don't support fiscal representatives - We don't support aggregate invoices - In the VAT rate we are missing the vat amount mismatch field (used when VAT has been charged under section 11 or 14) -- We don't support refund product charges (Field Product Fee Summary in the Invoice) \ No newline at end of file +- We don't support refund product charges (Field Product Fee Summary in the Invoice) + +- For each requestID, it is needed to have a new id for each request (non-repeat). For the moment, this is done just at random but there is a small probability that the request IDs match. + +- Nav supports 100 invoice creation/modification in the same request. For the moment, we only support 1 invoice at each request \ No newline at end of file diff --git a/examples/example.xml b/examples/example.xml new file mode 100644 index 0000000..c19ee40 --- /dev/null +++ b/examples/example.xml @@ -0,0 +1,97 @@ + + + ZZZ000015 + 2021-05-25 + false + + + + ZZZ000001 + false + 2 + + + + + 99999999 + 2 + 41 + + Értékesítő Kft + + + HU + 1234 + Budapest + Hármas + utca + 1 + + + 12345678-12345678-12345678 + + + DOMESTIC + + + 99887764 + 2 + 02 + + + Beszerző Kft + + + HU + 7600 + Pécs + Északi + sugárút + 123 + + + + + + NORMAL + 2021-05-10 + HUF + 1 + TRANSFER + PAPER + + M00001_S0102 + Módosítás oka + A ZZ000001 számla teljesítési időpontja 2021.05.10 a ZZZ000009 módosítón jelzett dátummal szemben. A számla korábban már módosított egyéb adatai változatlanok + + + + + + + + 0.27 + + + 0 + 0 + + + 0 + 0 + + + 0 + 0 + 0 + 0 + + + 0 + 0 + + + + + \ No newline at end of file diff --git a/examples/example1.xml b/examples/example1.xml new file mode 100644 index 0000000..f598f4d --- /dev/null +++ b/examples/example1.xml @@ -0,0 +1,97 @@ + + + ZZZ000015 + 2021-05-25 + false + + + + ZZZ000001 + false + 2 + + + + + 99999999 + 2 + 41 + + Értékesítő Kft + + + HU + 1234 + Budapest + Hármas + utca + 1 + + + 12345678-12345678-12345678 + + + DOMESTIC + + + 99887764 + 2 + 02 + + + Beszerző Kft + + + HU + 7600 + Pécs + Északi + sugárút + 123 + + + + + + NORMAL + 2021-05-10 + HUF + 1 + TRANSFER + PAPER + + M00001_S0102 + Módosítás oka + A ZZ000001 számla teljesítési időpontja 2021.05.10 a ZZZ000009 módosítón jelzett dátummal szemben. A számla korábban már módosított egyéb adatai változatlanok + + + + + + + + 0.27 + + + 0 + 0 + + + 0 + 0 + + + 0 + 0 + 0 + 0 + + + 0 + 0 + + + + + \ No newline at end of file diff --git a/go.mod b/go.mod index b7bad81..c923aff 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-resty/resty/v2 v2.14.0 github.com/invopop/gobl v0.113.0 github.com/joho/godotenv v1.5.1 + github.com/orcaman/concurrent-map/v2 v2.0.1 github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.25.0 ) diff --git a/go.sum b/go.sum index 15c3899..27ca1ff 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= +github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 h1:wD1IWQwAhdWclCwaf6DdzgCAe9Bfz1M+4AHRd7N786Y= diff --git a/internal/gateways/common.go b/internal/gateways/common.go index 953606e..7b24d8b 100644 --- a/internal/gateways/common.go +++ b/internal/gateways/common.go @@ -3,6 +3,7 @@ package gateways import ( "crypto/sha512" "encoding/hex" + "encoding/xml" "hash" "math/rand" "strings" @@ -12,6 +13,10 @@ import ( "golang.org/x/crypto/sha3" ) +const ( + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +) + type Header struct { RequestId string `xml:"common:requestId"` Timestamp string `xml:"common:timestamp"` @@ -47,10 +52,23 @@ type Software struct { SoftwareDevTaxNumber string `xml:"softwareDevTaxNumber"` } +type GeneralErrorResponse struct { + XMLName xml.Name `xml:"GeneralErrorResponse"` + Header *Header `xml:"header"` + Result *ErrorResult `xml:"result"` + Software *Software `xml:"software"` +} + +type ErrorResult struct { + FuncCode string `xml:"funcCode"` + ErrorCode string `xml:"errorCode"` + Message string `xml:"message"` +} + func NewHeader(requestID string, timestamp time.Time) *Header { return &Header{ RequestId: requestID, - Timestamp: timestamp.Format("2006-01-02T15:04:05.000Z"), + Timestamp: timestamp.Format("2006-01-02T15:04:05.00Z"), RequestVersion: "3.0", HeaderVersion: "1.0", } @@ -82,21 +100,19 @@ func hashPassword(password string) string { } func computeRequestSignature(requestID string, timestamp time.Time, signKey string, options ...string) string { - hash := sha3.New512() - timeSignature := timestamp.Format("20060102150405") hashBase := requestID + timeSignature + signKey if len(options) == 0 { - return hashInput(hash, hashBase) + return hashInput(sha3.New512(), hashBase) } - hashedInvoice := hashInput(hash, options[0]) + hashedInvoice := hashInput(sha3.New512(), options[0]) hashBase += hashedInvoice - return hashInput(hash, hashBase) + return hashInput(sha3.New512(), hashBase) } diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go deleted file mode 100644 index 862a9e5..0000000 --- a/internal/gateways/gateways.go +++ /dev/null @@ -1,16 +0,0 @@ -package gateways - -import ( - "time" - - cmap "github.com/orcaman/concurrent-map/v2" -) - -type gateways struct { - tokenCache cmap.ConcurrentMap[string, TokenInfo] -} - -type TokenInfo struct { - Token string - Expiration time.Time -} diff --git a/internal/gateways/manageInvoice.go b/internal/gateways/manageInvoice.go index 1d9ae14..481f71e 100644 --- a/internal/gateways/manageInvoice.go +++ b/internal/gateways/manageInvoice.go @@ -14,7 +14,7 @@ const ( // ManageInvoiceRequest represents the root element type ManageInvoiceRequest struct { - XMLName xml.Name `xml:"http://schemas.nav.gov.hu/OSA/3.0/api ManageInvoiceRequest"` + XMLName xml.Name `xml:"ManageInvoiceRequest"` Common string `xml:"xmlns:common,attr"` Xmlns string `xml:"xmlns,attr"` Header *Header `xml:"common:header"` @@ -44,12 +44,25 @@ type ElectronicInvoiceHash struct { Value string `xml:",chardata"` } +type ManageInvoiceResponse struct { + XMLName xml.Name `xml:"ManageInvoiceResponse"` + Header *Header `xml:"header"` + Result *Result `xml:"result"` + Software *Software `xml:"software"` + TransactionId string `xml:"transactionId"` +} + +func ReportInvoice(username string, password string, taxNumber string, signKey string, exchangeToken string, soft *Software, invoice string) error { + requestData := NewManageInvoiceRequest(username, password, taxNumber, signKey, exchangeToken, soft, invoice) + return PostManageInvoiceRequest(requestData) +} + func NewManageInvoiceRequest(username string, password string, taxNumber string, signKey string, exchangeToken string, soft *Software, invoice string) ManageInvoiceRequest { timestamp := time.Now().UTC() - requestID := generateRandomString(20) //This cannnot be repeated in the time window of 5 mins + requestID := generateRandomString(20) //This must be unique for each request operationType := "CREATE" return ManageInvoiceRequest{ - Common: "http://schemas.nav.gov.hu/OSA/3.0/common", + Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", Header: NewHeader(requestID, timestamp), User: NewUser(username, password, taxNumber, signKey, requestID, timestamp, operationType+invoice), @@ -68,7 +81,7 @@ func NewManageInvoiceRequest(username string, password string, taxNumber string, } } -func PostManageInvoiceRequest(requestData ManageInvoiceRequest) (string, error) { +func PostManageInvoiceRequest(requestData ManageInvoiceRequest) error { client := resty.New() resp, err := client.R(). @@ -78,12 +91,23 @@ func PostManageInvoiceRequest(requestData ManageInvoiceRequest) (string, error) Post(manageInvoiceEndpoint) if err != nil { - return "", err + return err } if resp.StatusCode() == 200 { - return resp.String(), nil + var manageInvoiceResponse ManageInvoiceResponse + err = xml.Unmarshal(resp.Body(), &manageInvoiceResponse) + if err != nil { + return err + } + return nil + } + + var generalErrorResponse GeneralErrorResponse + err = xml.Unmarshal(resp.Body(), &generalErrorResponse) + if err != nil { + return err } - return "", fmt.Errorf("error code: %s", resp.Status()) + return fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) } diff --git a/internal/gateways/tokenExchange.go b/internal/gateways/tokenExchange.go index 5cdbf02..9334cf3 100644 --- a/internal/gateways/tokenExchange.go +++ b/internal/gateways/tokenExchange.go @@ -11,10 +11,14 @@ import ( ) const ( - charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" tokenExchangeEndpoint = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/tokenExchange" ) +type TokenInfo struct { + Token string + Expiration time.Time +} + type TokenExchangeRequest struct { XMLName xml.Name `xml:"TokenExchangeRequest"` Common string `xml:"xmlns:common,attr"` @@ -38,23 +42,22 @@ type Result struct { FuncCode string `xml:"funcCode"` } -type GeneralErrorResponse struct { - XMLName xml.Name `xml:"GeneralErrorResponse"` - Header *Header `xml:"header"` - Result *ErrorResult `xml:"result"` - Software *Software `xml:"software"` -} +func GetToken(userName string, password string, signKey string, exchangeKey string, taxNumber string, soft *Software) (*TokenInfo, error) { + requestData := newTokenExchangeRequest(userName, password, signKey, taxNumber, soft) + token, err := postTokenExchangeRequest(requestData) + if err != nil { + return nil, err + } -type ErrorResult struct { - FuncCode string `xml:"funcCode"` - ErrorCode string `xml:"errorCode"` - Message string `xml:"message"` -} + err = token.decrypt(exchangeKey) + if err != nil { + return nil, err + } -//type Client struct { -// } + return token, nil +} -func PostTokenExchangeRequest(requestData TokenExchangeRequest) (string, error) { +func postTokenExchangeRequest(requestData TokenExchangeRequest) (*TokenInfo, error) { client := resty.New() resp, err := client.R(). @@ -64,32 +67,40 @@ func PostTokenExchangeRequest(requestData TokenExchangeRequest) (string, error) Post(tokenExchangeEndpoint) if err != nil { - return "", err + return nil, err } if resp.StatusCode() == 200 { var tokenExchangeResponse TokenExchangeResponse err = xml.Unmarshal(resp.Body(), &tokenExchangeResponse) if err != nil { - return "", err + return nil, err + } + + time, err := time.Parse("2006-01-02T15:04:05.000Z", tokenExchangeResponse.TokenValidityTo) + if err != nil { + return nil, err } - return tokenExchangeResponse.EncodedExchangeToken, nil + return &TokenInfo{ + Token: tokenExchangeResponse.EncodedExchangeToken, + Expiration: time, + }, nil } var generalErrorResponse GeneralErrorResponse err = xml.Unmarshal(resp.Body(), &generalErrorResponse) if err != nil { - return "", err + return nil, err } - return "", fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) + return nil, fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) } -func NewTokenExchangeRequest(userName string, password string, signKey string, taxNumber string, soft *Software) TokenExchangeRequest { +func newTokenExchangeRequest(userName string, password string, signKey string, taxNumber string, soft *Software) TokenExchangeRequest { timestamp := time.Now().UTC() - requestID := generateRandomString(20) //This cannnot be repeated in the time window of 5 mins + requestID := generateRandomString(20) //This must be unique for each request return TokenExchangeRequest{ Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", @@ -99,20 +110,23 @@ func NewTokenExchangeRequest(userName string, password string, signKey string, t } } -func decryptToken(encodedToken string, keyString string) (string, error) { +func (tok *TokenInfo) Expired() bool { + return time.Now().After(tok.Expiration) +} + +func (tokenInfo *TokenInfo) decrypt(keyString string) error { key := []byte(keyString) // Decode the base64 encoded encrypted key - ciphertext, err := base64.StdEncoding.DecodeString(encodedToken) + ciphertext, err := base64.StdEncoding.DecodeString(tokenInfo.Token) if err != nil { - return "", err + return err } // Create a new AES cipher block block, err := aes.NewCipher(key) if err != nil { - fmt.Println("Error creating cipher:", err) - return "", err + return err } // Create a buffer for the decrypted text @@ -126,7 +140,9 @@ func decryptToken(encodedToken string, keyString string) (string, error) { // Remove padding (if any) decrypted = unpad(decrypted) - return string(decrypted), nil + tokenInfo.Token = string(decrypted) + + return nil } // PKCS7 unpadding diff --git a/internal/gateways/tokenExchange_test.go b/internal/gateways/tokenExchange_test.go index 04c4cef..ef3bd89 100644 --- a/internal/gateways/tokenExchange_test.go +++ b/internal/gateways/tokenExchange_test.go @@ -31,15 +31,14 @@ func TestNewTokenExchangeRequest(t *testing.T) { userID := os.Getenv("USER_ID") userPWD := os.Getenv("USER_PWD") - signToken := os.Getenv("SIGN_TOKEN") + signKey := os.Getenv("SIGN_KEY") + exchangeKey := os.Getenv("EXCHANGE_KEY") taxID := os.Getenv("TAX_ID") - requestData := NewTokenExchangeRequest(userID, userPWD, signToken, taxID, software) - - // Execute the function - token, err := PostTokenExchangeRequest(requestData) + token, err := GetToken(userID, userPWD, signKey, exchangeKey, taxID, software) // Assert results require.NoError(t, err, "Expected no error from NewTokenExchangeRequest") - assert.NotEmpty(t, token, "Expected non-empty token from NewTokenExchangeRequest") + assert.NotNil(t, token, "Expected non-empty token from NewTokenExchangeRequest") + } diff --git a/nav.go b/nav.go index 8b5f683..f250e23 100644 --- a/nav.go +++ b/nav.go @@ -1 +1,49 @@ package nav + +import ( + "github.com/invopop/gobl.hu-nav/internal/gateways" + "github.com/invopop/gobl/tax" +) + +type nav struct { + login string + password string + signKey string + exchangeKey string + taxNumber string + software *gateways.Software + token *gateways.TokenInfo +} + +func NewNav(login, password, signKey, exchangeKey, taxNumber string, software *gateways.Software) *nav { + return &nav{ + login: login, + password: password, + signKey: signKey, + exchangeKey: exchangeKey, + taxNumber: taxNumber, + software: software, + } +} + +func NewSoftware(taxNumber tax.Identity, name string, operation string, version string, devName string, devContact string) *gateways.Software { + return gateways.NewSoftware(taxNumber, name, operation, version, devName, devContact) +} + +func (n *nav) ReportInvoice(invoice string) error { + // First check if we have a token and it is valid + if n.token == nil || n.token.Expired() { + token, err := gateways.GetToken(n.login, n.password, n.signKey, n.exchangeKey, n.taxNumber, n.software) + if err != nil { + return err + } + n.token = token + } + + // Now we can report the invoice + err := gateways.ReportInvoice(n.login, n.password, n.taxNumber, n.signKey, n.token.Token, n.software, invoice) + if err != nil { + return err + } + return nil +} diff --git a/nav_test.go b/nav_test.go new file mode 100644 index 0000000..9591342 --- /dev/null +++ b/nav_test.go @@ -0,0 +1,56 @@ +package nav + +import ( + "encoding/base64" + "log" + "os" + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/tax" + "github.com/joho/godotenv" + "github.com/stretchr/testify/require" +) + +func TestReportInvoice(t *testing.T) { + + software := NewSoftware( + tax.Identity{Country: l10n.ES.Tax(), Code: cbc.Code("B12345678")}, + "Invopop", + "ONLINE_SERVICE", + "1.0.0", + "TestDev", + "dev@test.com", + ) + + err := godotenv.Load(".env") + if err != nil { + log.Fatalf("Error loading .env file") + } + + userID := os.Getenv("USER_ID") + userPWD := os.Getenv("USER_PWD") + signKey := os.Getenv("SIGN_KEY") + exchangeKey := os.Getenv("EXCHANGE_KEY") + taxID := os.Getenv("TAX_ID") + + client := NewNav(userID, userPWD, signKey, exchangeKey, taxID, software) + + // Read and encode the invoice XML file + xmlContent, err := os.ReadFile("examples/example.xml") + if err != nil { + t.Fatalf("Failed to read sample invoice file: %v", err) + } + encodedInvoice := base64.StdEncoding.EncodeToString(xmlContent) + + // Call the function being tested + err = client.ReportInvoice(encodedInvoice) + + // Assert the result + if err != nil { + t.Errorf("ReportInvoice returned an unexpected error: %v", err) + } + + require.NoError(t, err, "Expected no error from NewTokenExchangeRequest") +} From 5c84f5f68fa7daaef9a4002abd21163d27642df2 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Wed, 28 Aug 2024 07:40:15 +0000 Subject: [PATCH 15/27] Get status done --- README.md | 12 ++- examples/example.xml | 77 ++++++++++------ examples/example1.xml | 97 -------------------- go.mod | 2 + internal/gateways/common.go | 26 ++++-- internal/gateways/manageInvoice.go | 14 +-- internal/gateways/status.go | 116 ++++++++++++++++++++++++ internal/gateways/status_test.go | 62 +++++++++++++ internal/gateways/tokenExchange.go | 14 +-- internal/gateways/tokenExchange_test.go | 3 + nav.go | 19 +++- nav_test.go | 2 +- 12 files changed, 294 insertions(+), 150 deletions(-) delete mode 100644 examples/example1.xml create mode 100644 internal/gateways/status.go create mode 100644 internal/gateways/status_test.go diff --git a/README.md b/README.md index 546650b..f5f8987 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,14 @@ The invoice data content of the data report must be embedded, encoded in BASE64 - For each requestID, it is needed to have a new id for each request (non-repeat). For the moment, this is done just at random but there is a small probability that the request IDs match. -- Nav supports 100 invoice creation/modification in the same request. For the moment, we only support 1 invoice at each request \ No newline at end of file +- Nav supports 100 invoice creation/modification in the same request. For the moment, we only support 1 invoice at each request. If this is changed, it should also be changed in status.go, as now status only answers with the errors/warning of the first invoice. + +## Invoice issuing order + +1. Generate the doc with the format required by the NAV. +2. Obtain an exchange token with 5 mins validity. +3. Issue the invoice (An Ok message when issuing the invoice doesn't mean that it is correctly issued) +4. Check your transaction status (This would give us information about the status of the invoice issuing) + +If you do step number 4 just after 3, you would get a status of processing, we should retry the operation until we get a status of DONE or ABORT. + diff --git a/examples/example.xml b/examples/example.xml index c19ee40..47df871 100644 --- a/examples/example.xml +++ b/examples/example.xml @@ -1,22 +1,17 @@ - ZZZ000015 - 2021-05-25 + SZ00004 + 2021-05-15 false - - ZZZ000001 - false - 2 - - 99999999 - 2 - 41 + 26763453 + 1 + 20 Értékesítő Kft @@ -53,20 +48,42 @@ xmlns:common="http://schemas.nav.gov.hu/NTCA/1.0/common" xmlns:base="http://sche - NORMAL - 2021-05-10 + 2021-05-15 + true HUF 1 TRANSFER + 2021-05-10 PAPER - - M00001_S0102 - Módosítás oka - A ZZ000001 számla teljesítési időpontja 2021.05.10 a ZZZ000009 módosítón jelzett dátummal szemben. A számla korábban már módosított egyéb adatai változatlanok - + + false + + + 1 + + true + + true + PRODUCT + Konyhabútor C típus + 1 + PIECE + 600000.00 + 600000.00 + + + 600000.00 + 600000.00 + + + 0.27 + + + + @@ -74,24 +91,28 @@ xmlns:common="http://schemas.nav.gov.hu/NTCA/1.0/common" xmlns:base="http://sche 0.27 - 0 - 0 + 600000.00 + 600000.00 - 0 - 0 + 162000.00 + 162000.00 + + 762000.00 + 762000.00 + - 0 - 0 - 0 - 0 + 600000 + 600000 + 162000 + 162000 - 0 - 0 + 762000 + 762000 - \ No newline at end of file + diff --git a/examples/example1.xml b/examples/example1.xml deleted file mode 100644 index f598f4d..0000000 --- a/examples/example1.xml +++ /dev/null @@ -1,97 +0,0 @@ - - - ZZZ000015 - 2021-05-25 - false - - - - ZZZ000001 - false - 2 - - - - - 99999999 - 2 - 41 - - Értékesítő Kft - - - HU - 1234 - Budapest - Hármas - utca - 1 - - - 12345678-12345678-12345678 - - - DOMESTIC - - - 99887764 - 2 - 02 - - - Beszerző Kft - - - HU - 7600 - Pécs - Északi - sugárút - 123 - - - - - - NORMAL - 2021-05-10 - HUF - 1 - TRANSFER - PAPER - - M00001_S0102 - Módosítás oka - A ZZ000001 számla teljesítési időpontja 2021.05.10 a ZZZ000009 módosítón jelzett dátummal szemben. A számla korábban már módosított egyéb adatai változatlanok - - - - - - - - 0.27 - - - 0 - 0 - - - 0 - 0 - - - 0 - 0 - 0 - 0 - - - 0 - 0 - - - - - \ No newline at end of file diff --git a/go.mod b/go.mod index c923aff..125453b 100644 --- a/go.mod +++ b/go.mod @@ -29,3 +29,5 @@ require ( golang.org/x/sys v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/invopop/gobl => ../gobl diff --git a/internal/gateways/common.go b/internal/gateways/common.go index 7b24d8b..d4902bc 100644 --- a/internal/gateways/common.go +++ b/internal/gateways/common.go @@ -53,16 +53,26 @@ type Software struct { } type GeneralErrorResponse struct { - XMLName xml.Name `xml:"GeneralErrorResponse"` - Header *Header `xml:"header"` - Result *ErrorResult `xml:"result"` - Software *Software `xml:"software"` + XMLName xml.Name `xml:"GeneralErrorResponse"` + Header *Header `xml:"header"` + Result *Result `xml:"result"` + Software *Software `xml:"software"` } -type ErrorResult struct { - FuncCode string `xml:"funcCode"` - ErrorCode string `xml:"errorCode"` - Message string `xml:"message"` +type Result struct { + FuncCode string `xml:"funcCode"` + ErrorCode string `xml:"errorCode,omitempty"` + Message string `xml:"message,omitempty"` + Notifications *Notifications `xml:"notifications,omitempty"` +} + +type Notifications struct { + Notification []*Notification `xml:"notification"` +} + +type Notification struct { + NotificationCode string `xml:"notificationCode"` + NotificationText string `xml:"notificationText"` } func NewHeader(requestID string, timestamp time.Time) *Header { diff --git a/internal/gateways/manageInvoice.go b/internal/gateways/manageInvoice.go index 481f71e..68e24c6 100644 --- a/internal/gateways/manageInvoice.go +++ b/internal/gateways/manageInvoice.go @@ -52,7 +52,7 @@ type ManageInvoiceResponse struct { TransactionId string `xml:"transactionId"` } -func ReportInvoice(username string, password string, taxNumber string, signKey string, exchangeToken string, soft *Software, invoice string) error { +func ReportInvoice(username string, password string, taxNumber string, signKey string, exchangeToken string, soft *Software, invoice string) (string, error) { requestData := NewManageInvoiceRequest(username, password, taxNumber, signKey, exchangeToken, soft, invoice) return PostManageInvoiceRequest(requestData) } @@ -81,7 +81,7 @@ func NewManageInvoiceRequest(username string, password string, taxNumber string, } } -func PostManageInvoiceRequest(requestData ManageInvoiceRequest) error { +func PostManageInvoiceRequest(requestData ManageInvoiceRequest) (string, error) { client := resty.New() resp, err := client.R(). @@ -91,23 +91,23 @@ func PostManageInvoiceRequest(requestData ManageInvoiceRequest) error { Post(manageInvoiceEndpoint) if err != nil { - return err + return "", err } if resp.StatusCode() == 200 { var manageInvoiceResponse ManageInvoiceResponse err = xml.Unmarshal(resp.Body(), &manageInvoiceResponse) if err != nil { - return err + return "", err } - return nil + return manageInvoiceResponse.TransactionId, nil } var generalErrorResponse GeneralErrorResponse err = xml.Unmarshal(resp.Body(), &generalErrorResponse) if err != nil { - return err + return "", err } - return fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) + return "", fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) } diff --git a/internal/gateways/status.go b/internal/gateways/status.go new file mode 100644 index 0000000..0399251 --- /dev/null +++ b/internal/gateways/status.go @@ -0,0 +1,116 @@ +package gateways + +import ( + "encoding/xml" + "fmt" + "time" + + "github.com/go-resty/resty/v2" +) + +const statusEndpoint = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/queryTransactionStatus" + +type QueryTransactionStatusRequest struct { + XMLName xml.Name `xml:"QueryTransactionStatusRequest"` + Common string `xml:"xmlns:common,attr"` + Xmlns string `xml:"xmlns,attr"` + Header *Header `xml:"common:header"` + User *User `xml:"common:user"` + Software *Software `xml:"software"` + TransactionId string `xml:"transactionId"` + ReturnOriginalRequest bool `xml:"returnOriginalRequest,omitempty"` +} + +type QueryTransactionStatusResponse struct { + XMLName xml.Name `xml:"QueryTransactionStatusResponse"` + Header *Header `xml:"header"` + Result *Result `xml:"result"` + Software *Software `xml:"software"` + ProcessingResults *ProcessingResults `xml:"processingResults"` +} + +type ProcessingResults struct { + ProcessingResult []*ProcessingResult `xml:"processingResult"` + OriginalRequestVersion string `xml:"originalRequestVersion"` + //AnnulmentData *AnnulmentData `xml:"annulmentData,omitempty"` +} + +type ProcessingResult struct { + Index string `xml:"index"` + BatchIndex string `xml:"batchIndex,omitempty"` + InvoiceStatus string `xml:"invoiceStatus"` + TechnicalValidationMessages *TechnicalValidationMessages `xml:"technicalValidationMessages,omitempty"` + BusinessValidationMessages *BusinessValidationMessages `xml:"businessValidationMessages,omitempty"` + CompressedContentIndicator bool `xml:"compressedContentIndicator"` + OriginalRequest string `xml:"originalRequest,omitempty"` +} + +type TechnicalValidationMessages struct { + ValidationResultCode string `xml:"validationResultCode"` + ValidationErrorCode string `xml:"validationErrorCode,omitempty"` + Message string `xml:"message,omitempty"` +} + +type BusinessValidationMessages struct { + ValidationResultCode string `xml:"validationResultCode"` + ValidationErrorCode string `xml:"validationErrorCode,omitempty"` + Message string `xml:"message,omitempty"` + Pointer *Pointer `xml:"pointer,omitempty"` +} + +type Pointer struct { + Tag string `xml:"tag,omitempty"` + Value string `xml:"value,omitempty"` + Line string `xml:"line,omitempty"` + OriginalInvoiceNumber string `xml:"originalInvoiceNumber,omitempty"` +} + +func GetStatus(username string, password string, taxNumber string, signKey string, soft *Software, transactionID string) (*ProcessingResult, error) { + requestData := NewQueryTransactionStatusRequest(username, password, taxNumber, signKey, soft, transactionID) + return QueryTransactionStatus(requestData) +} + +func NewQueryTransactionStatusRequest(username string, password string, taxNumber string, signKey string, soft *Software, transactionID string) QueryTransactionStatusRequest { + timestamp := time.Now().UTC() + requestID := generateRandomString(20) + return QueryTransactionStatusRequest{ + Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", + Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", + Header: NewHeader(requestID, timestamp), + User: NewUser(username, password, taxNumber, signKey, requestID, timestamp), + Software: soft, + TransactionId: transactionID, + } +} + +func QueryTransactionStatus(requestData QueryTransactionStatusRequest) (*ProcessingResult, error) { + client := resty.New() + + resp, err := client.R(). + SetHeader("Content-Type", "application/xml"). + SetHeader("Accept", "application/xml"). + SetBody(requestData). + Post(statusEndpoint) + + if err != nil { + return nil, err + } + + if resp.StatusCode() == 200 { + var queryTransactionStatusResponse QueryTransactionStatusResponse + err = xml.Unmarshal(resp.Body(), &queryTransactionStatusResponse) + if err != nil { + return nil, err + } + + return queryTransactionStatusResponse.ProcessingResults.ProcessingResult[0], nil + } + + var generalErrorResponse GeneralErrorResponse + err = xml.Unmarshal(resp.Body(), &generalErrorResponse) + if err != nil { + return nil, err + } + + return nil, fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) +} diff --git a/internal/gateways/status_test.go b/internal/gateways/status_test.go new file mode 100644 index 0000000..deed79c --- /dev/null +++ b/internal/gateways/status_test.go @@ -0,0 +1,62 @@ +package gateways + +import ( + "encoding/xml" + "fmt" + "log" + "os" + "testing" + + "github.com/invopop/gobl/cbc" + "github.com/invopop/gobl/l10n" + "github.com/invopop/gobl/tax" + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" +) + +func TestQueryTransactionStatus(t *testing.T) { + // Set up test data + software := NewSoftware( + tax.Identity{Country: l10n.ES.Tax(), Code: cbc.Code("B12345678")}, + "Invopop", + "ONLINE_SERVICE", + "1.0.0", + "TestDev", + "pablo.menendez@invopop.com", + ) + + err := godotenv.Load("../../.env") + if err != nil { + log.Fatalf("Error loading .env file") + } + + userID := os.Getenv("USER_ID") + userPWD := os.Getenv("USER_PWD") + signKey := os.Getenv("SIGN_KEY") + taxID := os.Getenv("TAX_ID") + + requestData := NewQueryTransactionStatusRequest(userID, userPWD, taxID, signKey, software, "4OYE2J5GEOGWKMYV") + + result, err := QueryTransactionStatus(requestData) + + if err != nil { + fmt.Printf("Error querying transaction status: %v\n", err) + return + } + + // Print result in xml format for debugging + xmlData, err := xml.MarshalIndent(result, "", " ") + if err != nil { + fmt.Printf("Error marshalling to XML: %v\n", err) + return + } + + fmt.Println(string(xmlData)) + + // Assert the results + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "1", result.Index) + assert.Equal(t, "DONE", result.InvoiceStatus) + assert.False(t, result.CompressedContentIndicator) +} diff --git a/internal/gateways/tokenExchange.go b/internal/gateways/tokenExchange.go index 9334cf3..631bd74 100644 --- a/internal/gateways/tokenExchange.go +++ b/internal/gateways/tokenExchange.go @@ -38,10 +38,6 @@ type TokenExchangeResponse struct { TokenValidityTo string `xml:"tokenValidityTo"` } -type Result struct { - FuncCode string `xml:"funcCode"` -} - func GetToken(userName string, password string, signKey string, exchangeKey string, taxNumber string, soft *Software) (*TokenInfo, error) { requestData := newTokenExchangeRequest(userName, password, signKey, taxNumber, soft) token, err := postTokenExchangeRequest(requestData) @@ -77,14 +73,18 @@ func postTokenExchangeRequest(requestData TokenExchangeRequest) (*TokenInfo, err return nil, err } - time, err := time.Parse("2006-01-02T15:04:05.000Z", tokenExchangeResponse.TokenValidityTo) + var expirationTime time.Time + expirationTime, err = time.Parse("2006-01-02T15:04:05.000Z", tokenExchangeResponse.TokenValidityTo) if err != nil { - return nil, err + expirationTime, err = time.Parse("2006-01-02T15:04:05.00Z", tokenExchangeResponse.TokenValidityTo) + if err != nil { + return nil, err + } } return &TokenInfo{ Token: tokenExchangeResponse.EncodedExchangeToken, - Expiration: time, + Expiration: expirationTime, }, nil } diff --git a/internal/gateways/tokenExchange_test.go b/internal/gateways/tokenExchange_test.go index ef3bd89..f7ef255 100644 --- a/internal/gateways/tokenExchange_test.go +++ b/internal/gateways/tokenExchange_test.go @@ -1,6 +1,7 @@ package gateways import ( + "fmt" "log" "os" "testing" @@ -41,4 +42,6 @@ func TestNewTokenExchangeRequest(t *testing.T) { require.NoError(t, err, "Expected no error from NewTokenExchangeRequest") assert.NotNil(t, token, "Expected non-empty token from NewTokenExchangeRequest") + fmt.Println("Token: ", token.Token) + } diff --git a/nav.go b/nav.go index f250e23..e93f91d 100644 --- a/nav.go +++ b/nav.go @@ -1,6 +1,9 @@ package nav import ( + "encoding/xml" + "fmt" + "github.com/invopop/gobl.hu-nav/internal/gateways" "github.com/invopop/gobl/tax" ) @@ -41,9 +44,23 @@ func (n *nav) ReportInvoice(invoice string) error { } // Now we can report the invoice - err := gateways.ReportInvoice(n.login, n.password, n.taxNumber, n.signKey, n.token.Token, n.software, invoice) + transactionId, err := gateways.ReportInvoice(n.login, n.password, n.taxNumber, n.signKey, n.token.Token, n.software, invoice) + if err != nil { + return err + } + + fmt.Println("Transaction ID: ", transactionId) + + status, err := gateways.GetStatus(n.login, n.password, n.taxNumber, n.signKey, n.software, transactionId) if err != nil { return err } + // Print result in xml format for debugging + xmlData, err := xml.MarshalIndent(status, "", " ") + if err != nil { + return err + } + + fmt.Println(string(xmlData)) return nil } diff --git a/nav_test.go b/nav_test.go index 9591342..b7a7423 100644 --- a/nav_test.go +++ b/nav_test.go @@ -21,7 +21,7 @@ func TestReportInvoice(t *testing.T) { "ONLINE_SERVICE", "1.0.0", "TestDev", - "dev@test.com", + "pablo.menendez@invopop.com", ) err := godotenv.Load(".env") From db7fcd2162527aea7d51d562e8bb91b737e151d4 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Wed, 28 Aug 2024 11:24:15 +0000 Subject: [PATCH 16/27] Gateways done --- internal/gateways/common.go | 47 +++++++--- internal/gateways/gateways.go | 69 +++++++++++++++ .../gateways/{manageInvoice.go => invoice.go} | 34 +++---- internal/gateways/status.go | 44 ++++------ internal/gateways/status_test.go | 27 +++--- .../gateways/{tokenExchange.go => token.go} | 55 ++++++------ .../{tokenExchange_test.go => token_test.go} | 13 +-- nav.go | 88 +++++++++---------- nav_test.go | 28 ++++-- 9 files changed, 248 insertions(+), 157 deletions(-) create mode 100644 internal/gateways/gateways.go rename internal/gateways/{manageInvoice.go => invoice.go} (71%) rename internal/gateways/{tokenExchange.go => token.go} (67%) rename internal/gateways/{tokenExchange_test.go => token_test.go} (75%) diff --git a/internal/gateways/common.go b/internal/gateways/common.go index d4902bc..397679f 100644 --- a/internal/gateways/common.go +++ b/internal/gateways/common.go @@ -14,9 +14,13 @@ import ( ) const ( - charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + RequestVersion = "3.0" + HeaderVersion = "1.0" ) +// Header is the common header for all requests +// A new RequestId and Timestamp is generated for each request type Header struct { RequestId string `xml:"common:requestId"` Timestamp string `xml:"common:timestamp"` @@ -24,23 +28,28 @@ type Header struct { HeaderVersion string `xml:"common:headerVersion"` } -type User struct { +// UserRequest is the common user for all requests. Login, PasswordHash and TaxNumber remain the same, +// while RequestSignature is computed differently for manageInvoice. +type UserRequest struct { Login string `xml:"common:login"` PasswordHash PasswordHash `xml:"common:passwordHash"` TaxNumber string `xml:"common:taxNumber"` RequestSignature RequestSignature `xml:"common:requestSignature"` } +// PasswordHash is the hash of the password type PasswordHash struct { CryptoType string `xml:"cryptoType,attr"` Value string `xml:",chardata"` } +// RequestSignature is the signature of the request. It is computed differently for manageInvoice. type RequestSignature struct { CryptoType string `xml:"cryptoType,attr"` Value string `xml:",chardata"` } +// Software is the information about the software used for issuing the invoices type Software struct { SoftwareId string `xml:"softwareId"` SoftwareName string `xml:"softwareName"` @@ -52,6 +61,7 @@ type Software struct { SoftwareDevTaxNumber string `xml:"softwareDevTaxNumber"` } +// GeneralErrorResponse is the common error response for all requests type GeneralErrorResponse struct { XMLName xml.Name `xml:"GeneralErrorResponse"` Header *Header `xml:"header"` @@ -59,6 +69,8 @@ type GeneralErrorResponse struct { Software *Software `xml:"software"` } +// Result is the common result for all requests +// If request is OK, only FuncCode is returned type Result struct { FuncCode string `xml:"funcCode"` ErrorCode string `xml:"errorCode,omitempty"` @@ -66,36 +78,40 @@ type Result struct { Notifications *Notifications `xml:"notifications,omitempty"` } +// Notifications is the list of notifications type Notifications struct { Notification []*Notification `xml:"notification"` } +// Notification includes a code and a text type Notification struct { NotificationCode string `xml:"notificationCode"` NotificationText string `xml:"notificationText"` } +// NewHeader creates a new Header with the given requestID and timestamp func NewHeader(requestID string, timestamp time.Time) *Header { return &Header{ RequestId: requestID, Timestamp: timestamp.Format("2006-01-02T15:04:05.00Z"), - RequestVersion: "3.0", - HeaderVersion: "1.0", + RequestVersion: RequestVersion, + HeaderVersion: HeaderVersion, } } -func NewUser(userName string, password string, taxNumber string, signKey string, requestID string, timestamp time.Time, options ...string) *User { +// NewUser creates a new User +func (g *Client) NewUser(requestID string, timestamp time.Time, options ...string) *UserRequest { signature := "" if len(options) > 0 { base := options[0] - signature = computeRequestSignature(requestID, timestamp, signKey, base) + signature = computeRequestSignature(requestID, timestamp, g.user.signKey, base) } else { - signature = computeRequestSignature(requestID, timestamp, signKey) + signature = computeRequestSignature(requestID, timestamp, g.user.signKey) } - return &User{ - Login: userName, - PasswordHash: PasswordHash{CryptoType: "SHA-512", Value: hashPassword(password)}, - TaxNumber: taxNumber, + return &UserRequest{ + Login: g.user.login, + PasswordHash: PasswordHash{CryptoType: "SHA-512", Value: hashPassword(g.user.password)}, + TaxNumber: g.user.taxNumber, RequestSignature: RequestSignature{ CryptoType: "SHA3-512", Value: signature, @@ -126,6 +142,7 @@ func computeRequestSignature(requestID string, timestamp time.Time, signKey stri } +// NewSoftware creates a new Software with the information about the software developer func NewSoftware(taxNumber tax.Identity, name string, operation string, version string, devName string, devContact string) *Software { if operation != "ONLINE_SERVICE" && operation != "LOCAL_SOFTWARE" { @@ -154,6 +171,14 @@ func NewSoftwareID(taxNumber tax.Identity) string { return taxNumber.String() + generateRandomString(lenRandom) } +func NewRequestID(timestamp time.Time) string { + timeUnique := timestamp.Format("20060102150405") + + randomNumber := rand.Intn(17) + + return timeUnique + generateRandomString(randomNumber) +} + func generateRandomString(length int) string { b := make([]byte, length) for i := range b { diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go new file mode 100644 index 0000000..71763dc --- /dev/null +++ b/internal/gateways/gateways.go @@ -0,0 +1,69 @@ +package gateways + +import ( + "github.com/go-resty/resty/v2" +) + +type Client struct { + user *User + software *Software + token *TokenInfo + + rest *resty.Client +} + +type User struct { + login string + password string + signKey string + exchangeKey string + taxNumber string +} + +// Environment defines the environment to use for connections +type Environment string + +// Environment to use for connections +const ( + EnvironmentProduction Environment = "production" + EnvironmentTesting Environment = "testing" + NavProductionURL = "https://api.onlineszamla.nav.gov.hu/" + NavTestingURL = "https://api-test.onlineszamla.nav.gov.hu/" + + APIXMNLS = "http://schemas.nav.gov.hu/OSA/3.0/api" + APICommon = "http://schemas.nav.gov.hu/NTCA/1.0/common" + + TokenExchangeEndpoint = "invoiceService/v3/tokenExchange" + ManageInvoiceEndpoint = "invoiceService/v3/manageInvoice" + StatusEndpoint = "invoiceService/v3/queryTransactionStatus" +) + +// NewGateways creates a new gateways instance +func New(user *User, software *Software, environment Environment) *Client { + c := &Client{ + user: user, + software: software, + } + + c.rest = resty.New() + + switch environment { + case EnvironmentProduction: + c.rest = c.rest.SetBaseURL(NavProductionURL) + default: + c.rest = c.rest.SetBaseURL(NavTestingURL) + } + + return c +} + +// NewUser creates a new User instance +func NewUser(login, password, signKey, exchangeKey, taxNumber string) *User { + return &User{ + login: login, + password: password, + signKey: signKey, + exchangeKey: exchangeKey, + taxNumber: taxNumber, + } +} diff --git a/internal/gateways/manageInvoice.go b/internal/gateways/invoice.go similarity index 71% rename from internal/gateways/manageInvoice.go rename to internal/gateways/invoice.go index 68e24c6..fc566d2 100644 --- a/internal/gateways/manageInvoice.go +++ b/internal/gateways/invoice.go @@ -4,12 +4,6 @@ import ( "encoding/xml" "fmt" "time" - - "github.com/go-resty/resty/v2" -) - -const ( - manageInvoiceEndpoint = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/manageInvoice" ) // ManageInvoiceRequest represents the root element @@ -18,7 +12,7 @@ type ManageInvoiceRequest struct { Common string `xml:"xmlns:common,attr"` Xmlns string `xml:"xmlns,attr"` Header *Header `xml:"common:header"` - User *User `xml:"common:user"` + User *UserRequest `xml:"common:user"` Software *Software `xml:"software"` ExchangeToken string `xml:"exchangeToken"` InvoiceOperations *InvoiceOperations `xml:"invoiceOperations"` @@ -52,22 +46,22 @@ type ManageInvoiceResponse struct { TransactionId string `xml:"transactionId"` } -func ReportInvoice(username string, password string, taxNumber string, signKey string, exchangeToken string, soft *Software, invoice string) (string, error) { - requestData := NewManageInvoiceRequest(username, password, taxNumber, signKey, exchangeToken, soft, invoice) - return PostManageInvoiceRequest(requestData) +func (g *Client) ReportInvoice(invoice string) (string, error) { + requestData := g.newManageInvoiceRequest(invoice) + return g.postManageInvoiceRequest(requestData) } -func NewManageInvoiceRequest(username string, password string, taxNumber string, signKey string, exchangeToken string, soft *Software, invoice string) ManageInvoiceRequest { +func (g *Client) newManageInvoiceRequest(invoice string) ManageInvoiceRequest { timestamp := time.Now().UTC() - requestID := generateRandomString(20) //This must be unique for each request - operationType := "CREATE" + requestID := NewRequestID(timestamp) + operationType := "CREATE" // For the moment, only CREATE is supported return ManageInvoiceRequest{ Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", Header: NewHeader(requestID, timestamp), - User: NewUser(username, password, taxNumber, signKey, requestID, timestamp, operationType+invoice), - Software: soft, - ExchangeToken: exchangeToken, + User: g.NewUser(requestID, timestamp, operationType+invoice), + Software: g.software, + ExchangeToken: g.token.Token, InvoiceOperations: &InvoiceOperations{ CompressedContent: false, InvoiceOperation: []*InvoiceOperation{ @@ -81,14 +75,12 @@ func NewManageInvoiceRequest(username string, password string, taxNumber string, } } -func PostManageInvoiceRequest(requestData ManageInvoiceRequest) (string, error) { - client := resty.New() - - resp, err := client.R(). +func (g *Client) postManageInvoiceRequest(requestData ManageInvoiceRequest) (string, error) { + resp, err := g.rest.R(). SetHeader("Content-Type", "application/xml"). SetHeader("Accept", "application/xml"). SetBody(requestData). - Post(manageInvoiceEndpoint) + Post(ManageInvoiceEndpoint) if err != nil { return "", err diff --git a/internal/gateways/status.go b/internal/gateways/status.go index 0399251..ea8be0c 100644 --- a/internal/gateways/status.go +++ b/internal/gateways/status.go @@ -4,21 +4,17 @@ import ( "encoding/xml" "fmt" "time" - - "github.com/go-resty/resty/v2" ) -const statusEndpoint = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/queryTransactionStatus" - type QueryTransactionStatusRequest struct { - XMLName xml.Name `xml:"QueryTransactionStatusRequest"` - Common string `xml:"xmlns:common,attr"` - Xmlns string `xml:"xmlns,attr"` - Header *Header `xml:"common:header"` - User *User `xml:"common:user"` - Software *Software `xml:"software"` - TransactionId string `xml:"transactionId"` - ReturnOriginalRequest bool `xml:"returnOriginalRequest,omitempty"` + XMLName xml.Name `xml:"QueryTransactionStatusRequest"` + Common string `xml:"xmlns:common,attr"` + Xmlns string `xml:"xmlns,attr"` + Header *Header `xml:"common:header"` + User *UserRequest `xml:"common:user"` + Software *Software `xml:"software"` + TransactionId string `xml:"transactionId"` + ReturnOriginalRequest bool `xml:"returnOriginalRequest,omitempty"` } type QueryTransactionStatusResponse struct { @@ -65,32 +61,30 @@ type Pointer struct { OriginalInvoiceNumber string `xml:"originalInvoiceNumber,omitempty"` } -func GetStatus(username string, password string, taxNumber string, signKey string, soft *Software, transactionID string) (*ProcessingResult, error) { - requestData := NewQueryTransactionStatusRequest(username, password, taxNumber, signKey, soft, transactionID) - return QueryTransactionStatus(requestData) +func (g *Client) GetStatus(transactionID string) ([]*ProcessingResult, error) { + requestData := g.newQueryTransactionStatusRequest(transactionID) + return g.queryTransactionStatus(requestData) } -func NewQueryTransactionStatusRequest(username string, password string, taxNumber string, signKey string, soft *Software, transactionID string) QueryTransactionStatusRequest { +func (g *Client) newQueryTransactionStatusRequest(transactionID string) QueryTransactionStatusRequest { timestamp := time.Now().UTC() - requestID := generateRandomString(20) + requestID := NewRequestID(timestamp) return QueryTransactionStatusRequest{ Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", Header: NewHeader(requestID, timestamp), - User: NewUser(username, password, taxNumber, signKey, requestID, timestamp), - Software: soft, + User: g.NewUser(requestID, timestamp), + Software: g.software, TransactionId: transactionID, } } -func QueryTransactionStatus(requestData QueryTransactionStatusRequest) (*ProcessingResult, error) { - client := resty.New() - - resp, err := client.R(). +func (g *Client) queryTransactionStatus(requestData QueryTransactionStatusRequest) ([]*ProcessingResult, error) { + resp, err := g.rest.R(). SetHeader("Content-Type", "application/xml"). SetHeader("Accept", "application/xml"). SetBody(requestData). - Post(statusEndpoint) + Post(StatusEndpoint) if err != nil { return nil, err @@ -103,7 +97,7 @@ func QueryTransactionStatus(requestData QueryTransactionStatusRequest) (*Process return nil, err } - return queryTransactionStatusResponse.ProcessingResults.ProcessingResult[0], nil + return queryTransactionStatusResponse.ProcessingResults.ProcessingResult, nil } var generalErrorResponse GeneralErrorResponse diff --git a/internal/gateways/status_test.go b/internal/gateways/status_test.go index deed79c..ccf8aa3 100644 --- a/internal/gateways/status_test.go +++ b/internal/gateways/status_test.go @@ -24,7 +24,6 @@ func TestQueryTransactionStatus(t *testing.T) { "TestDev", "pablo.menendez@invopop.com", ) - err := godotenv.Load("../../.env") if err != nil { log.Fatalf("Error loading .env file") @@ -33,16 +32,18 @@ func TestQueryTransactionStatus(t *testing.T) { userID := os.Getenv("USER_ID") userPWD := os.Getenv("USER_PWD") signKey := os.Getenv("SIGN_KEY") + exchangeKey := os.Getenv("EXCHANGE_KEY") taxID := os.Getenv("TAX_ID") - requestData := NewQueryTransactionStatusRequest(userID, userPWD, taxID, signKey, software, "4OYE2J5GEOGWKMYV") + user := NewUser(userID, userPWD, signKey, exchangeKey, taxID) - result, err := QueryTransactionStatus(requestData) + client := New(user, software, Environment("testing")) - if err != nil { - fmt.Printf("Error querying transaction status: %v\n", err) - return - } + result, err := client.GetStatus("4OYE2J5GEOGWKMYV") + + // Assert the results + assert.NoError(t, err) + assert.NotNil(t, result) // Print result in xml format for debugging xmlData, err := xml.MarshalIndent(result, "", " ") @@ -53,10 +54,10 @@ func TestQueryTransactionStatus(t *testing.T) { fmt.Println(string(xmlData)) - // Assert the results - assert.NoError(t, err) - assert.NotNil(t, result) - assert.Equal(t, "1", result.Index) - assert.Equal(t, "DONE", result.InvoiceStatus) - assert.False(t, result.CompressedContentIndicator) + for _, r := range result { + assert.Equal(t, "1", r.Index) + assert.Equal(t, "DONE", r.InvoiceStatus) + assert.False(t, r.CompressedContentIndicator) + } + } diff --git a/internal/gateways/tokenExchange.go b/internal/gateways/token.go similarity index 67% rename from internal/gateways/tokenExchange.go rename to internal/gateways/token.go index 631bd74..7d9749f 100644 --- a/internal/gateways/tokenExchange.go +++ b/internal/gateways/token.go @@ -6,12 +6,6 @@ import ( "encoding/xml" "fmt" "time" - - "github.com/go-resty/resty/v2" -) - -const ( - tokenExchangeEndpoint = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/tokenExchange" ) type TokenInfo struct { @@ -20,12 +14,12 @@ type TokenInfo struct { } type TokenExchangeRequest struct { - XMLName xml.Name `xml:"TokenExchangeRequest"` - Common string `xml:"xmlns:common,attr"` - Xmlns string `xml:"xmlns,attr"` - Header *Header `xml:"common:header"` - User *User `xml:"common:user"` - Software *Software `xml:"software"` + XMLName xml.Name `xml:"TokenExchangeRequest"` + Common string `xml:"xmlns:common,attr"` + Xmlns string `xml:"xmlns,attr"` + Header *Header `xml:"common:header"` + User *UserRequest `xml:"common:user"` + Software *Software `xml:"software"` } type TokenExchangeResponse struct { @@ -38,29 +32,30 @@ type TokenExchangeResponse struct { TokenValidityTo string `xml:"tokenValidityTo"` } -func GetToken(userName string, password string, signKey string, exchangeKey string, taxNumber string, soft *Software) (*TokenInfo, error) { - requestData := newTokenExchangeRequest(userName, password, signKey, taxNumber, soft) - token, err := postTokenExchangeRequest(requestData) +func (g *Client) GetToken() error { + requestData := g.newTokenExchangeRequest() + + token, err := g.postTokenExchangeRequest(requestData) if err != nil { - return nil, err + return err } - err = token.decrypt(exchangeKey) + err = token.decrypt(g.user.exchangeKey) if err != nil { - return nil, err + return err } - return token, nil -} + g.token = token -func postTokenExchangeRequest(requestData TokenExchangeRequest) (*TokenInfo, error) { - client := resty.New() + return nil +} - resp, err := client.R(). +func (g *Client) postTokenExchangeRequest(requestData TokenExchangeRequest) (*TokenInfo, error) { + resp, err := g.rest.R(). SetHeader("Content-Type", "application/xml"). SetHeader("Accept", "application/xml"). SetBody(requestData). - Post(tokenExchangeEndpoint) + Post(TokenExchangeEndpoint) if err != nil { return nil, err @@ -98,15 +93,15 @@ func postTokenExchangeRequest(requestData TokenExchangeRequest) (*TokenInfo, err } -func newTokenExchangeRequest(userName string, password string, signKey string, taxNumber string, soft *Software) TokenExchangeRequest { +func (g *Client) newTokenExchangeRequest() TokenExchangeRequest { timestamp := time.Now().UTC() - requestID := generateRandomString(20) //This must be unique for each request + requestID := NewRequestID(timestamp) //This must be unique for each request return TokenExchangeRequest{ - Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", - Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", + Xmlns: APIXMNLS, + Common: APICommon, Header: NewHeader(requestID, timestamp), - User: NewUser(userName, password, taxNumber, signKey, requestID, timestamp), - Software: soft, + User: g.NewUser(requestID, timestamp), + Software: g.software, } } diff --git a/internal/gateways/tokenExchange_test.go b/internal/gateways/token_test.go similarity index 75% rename from internal/gateways/tokenExchange_test.go rename to internal/gateways/token_test.go index f7ef255..dd60019 100644 --- a/internal/gateways/tokenExchange_test.go +++ b/internal/gateways/token_test.go @@ -1,7 +1,6 @@ package gateways import ( - "fmt" "log" "os" "testing" @@ -14,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewTokenExchangeRequest(t *testing.T) { +func TestGetToken(t *testing.T) { // Set up test data software := NewSoftware( tax.Identity{Country: l10n.ES.Tax(), Code: cbc.Code("B12345678")}, @@ -36,12 +35,14 @@ func TestNewTokenExchangeRequest(t *testing.T) { exchangeKey := os.Getenv("EXCHANGE_KEY") taxID := os.Getenv("TAX_ID") - token, err := GetToken(userID, userPWD, signKey, exchangeKey, taxID, software) + user := NewUser(userID, userPWD, signKey, exchangeKey, taxID) + + client := New(user, software, Environment("testing")) + + err = client.GetToken() // Assert results require.NoError(t, err, "Expected no error from NewTokenExchangeRequest") - assert.NotNil(t, token, "Expected non-empty token from NewTokenExchangeRequest") - - fmt.Println("Token: ", token.Token) + assert.NotNil(t, client.token, "Expected non-empty token from NewTokenExchangeRequest") } diff --git a/nav.go b/nav.go index e93f91d..43e7ea0 100644 --- a/nav.go +++ b/nav.go @@ -1,66 +1,62 @@ package nav import ( - "encoding/xml" - "fmt" - "github.com/invopop/gobl.hu-nav/internal/gateways" "github.com/invopop/gobl/tax" ) -type nav struct { - login string - password string - signKey string - exchangeKey string - taxNumber string - software *gateways.Software - token *gateways.TokenInfo +type Nav struct { + gw *gateways.Client + env gateways.Environment } -func NewNav(login, password, signKey, exchangeKey, taxNumber string, software *gateways.Software) *nav { - return &nav{ - login: login, - password: password, - signKey: signKey, - exchangeKey: exchangeKey, - taxNumber: taxNumber, - software: software, +type Option func(*Nav) + +func NewNav(user *gateways.User, software *gateways.Software, opts ...Option) *Nav { + + c := new(Nav) + + for _, opt := range opts { + opt(c) } -} -func NewSoftware(taxNumber tax.Identity, name string, operation string, version string, devName string, devContact string) *gateways.Software { - return gateways.NewSoftware(taxNumber, name, operation, version, devName, devContact) + c.gw = gateways.New(user, software, c.env) + + return c } -func (n *nav) ReportInvoice(invoice string) error { - // First check if we have a token and it is valid - if n.token == nil || n.token.Expired() { - token, err := gateways.GetToken(n.login, n.password, n.signKey, n.exchangeKey, n.taxNumber, n.software) - if err != nil { - return err - } - n.token = token +// InProduction defines the connection to use the production environment. +func InProduction() Option { + return func(c *Nav) { + c.env = gateways.EnvironmentProduction } +} - // Now we can report the invoice - transactionId, err := gateways.ReportInvoice(n.login, n.password, n.taxNumber, n.signKey, n.token.Token, n.software, invoice) - if err != nil { - return err +// InTesting defines the connection to use the testing environment. +func InTesting() Option { + return func(c *Nav) { + c.env = gateways.EnvironmentTesting } +} - fmt.Println("Transaction ID: ", transactionId) +func (n *Nav) FetchToken() error { + return n.gw.GetToken() +} - status, err := gateways.GetStatus(n.login, n.password, n.taxNumber, n.signKey, n.software, transactionId) - if err != nil { - return err - } - // Print result in xml format for debugging - xmlData, err := xml.MarshalIndent(status, "", " ") - if err != nil { - return err - } +func (n *Nav) ReportInvoice(invoice string) (string, error) { + return n.gw.ReportInvoice(invoice) +} + +func (n *Nav) GetTransactionStatus(transactionId string) ([]*gateways.ProcessingResult, error) { + return n.gw.GetStatus(transactionId) +} + +// NewSoftware creates a new Software with the information about the software developer +func NewSoftware(taxNumber tax.Identity, name string, operation string, version string, devName string, devContact string) *gateways.Software { + return gateways.NewSoftware(taxNumber, name, operation, version, devName, devContact) +} - fmt.Println(string(xmlData)) - return nil +// NewUser creates a new User +func NewUser(login string, password string, signKey string, exchangeKey string, taxNumber string) *gateways.User { + return gateways.NewUser(login, password, signKey, exchangeKey, taxNumber) } diff --git a/nav_test.go b/nav_test.go index b7a7423..5cc3d5f 100644 --- a/nav_test.go +++ b/nav_test.go @@ -2,6 +2,8 @@ package nav import ( "encoding/base64" + "encoding/xml" + "fmt" "log" "os" "testing" @@ -35,22 +37,38 @@ func TestReportInvoice(t *testing.T) { exchangeKey := os.Getenv("EXCHANGE_KEY") taxID := os.Getenv("TAX_ID") - client := NewNav(userID, userPWD, signKey, exchangeKey, taxID, software) + user := NewUser(userID, userPWD, signKey, exchangeKey, taxID) + + navClient := NewNav(user, software, InTesting()) - // Read and encode the invoice XML file xmlContent, err := os.ReadFile("examples/example.xml") if err != nil { t.Fatalf("Failed to read sample invoice file: %v", err) } encodedInvoice := base64.StdEncoding.EncodeToString(xmlContent) - // Call the function being tested - err = client.ReportInvoice(encodedInvoice) + navClient.FetchToken() + + transactionId, err := navClient.ReportInvoice(encodedInvoice) // Assert the result if err != nil { t.Errorf("ReportInvoice returned an unexpected error: %v", err) } + require.NoError(t, err, "Expected no error") + + resultsList, err := navClient.GetTransactionStatus(transactionId) + if err != nil { + t.Errorf("GetTransactionStatus returned an unexpected error: %v", err) + } + require.NoError(t, err, "Expected no error") + + // Print result in xml format for debugging + xmlData, err := xml.MarshalIndent(resultsList, "", " ") + if err != nil { + fmt.Printf("Error marshalling to XML: %v\n", err) + return + } - require.NoError(t, err, "Expected no error from NewTokenExchangeRequest") + fmt.Println(string(xmlData)) } From 3607f05fc97fe6b243b25a5eaa8d1c7a129f047a Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Wed, 28 Aug 2024 11:30:35 +0000 Subject: [PATCH 17/27] modifying the example --- examples/example.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example.xml b/examples/example.xml index 47df871..a741fdb 100644 --- a/examples/example.xml +++ b/examples/example.xml @@ -9,7 +9,7 @@ xmlns:common="http://schemas.nav.gov.hu/NTCA/1.0/common" xmlns:base="http://sche - 26763453 + 12345678 1 20 From f29644cc992eef84b81c5b952ed1d815fc02201e Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Thu, 29 Aug 2024 10:34:07 +0000 Subject: [PATCH 18/27] Schema validation --- README.md | 6 +- go.mod | 5 +- go.sum | 10 +- internal/doc/customer.go | 6 +- internal/doc/doc.go | 30 ++++-- internal/doc/doc_test.go | 91 ++++++++++++++++ internal/doc/invoice.go | 10 +- internal/doc/lines.go | 60 +++++------ internal/doc/lines_test.go | 1 - internal/doc/summary.go | 36 +++++-- internal/doc/supplier.go | 5 - internal/doc/supplier_test.go | 14 --- internal/doc/taxnumber.go | 3 - internal/doc/taxnumber_test.go | 15 --- internal/doc/vatrate.go | 25 ++--- internal/doc/vatrate_test.go | 4 +- internal/gateways/status_test.go | 2 +- nav_test.go | 4 +- test/data/invoice_test.json | 126 ++++++++++++++++++++++ test/data/out/output.xml | 95 ++++++++++++++++ {schemas => test/schemas}/common.xsd | 0 {schemas => test/schemas}/invoiceBase.xsd | 0 {schemas => test/schemas}/invoiceData.xsd | 6 +- 23 files changed, 423 insertions(+), 131 deletions(-) create mode 100644 internal/doc/doc_test.go create mode 100644 test/data/invoice_test.json create mode 100644 test/data/out/output.xml rename {schemas => test/schemas}/common.xsd (100%) rename {schemas => test/schemas}/invoiceBase.xsd (100%) rename {schemas => test/schemas}/invoiceData.xsd (97%) diff --git a/README.md b/README.md index f5f8987..87aec4c 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ Convert GOBL into Hungarian NAV XML documents The invoice data content of the data report must be embedded, encoded in BASE64 format, in the ManageInvoiceRequest/invoiceoperations/invoiceOperation/InvoiceData element. -## Limitations +## Limitations/Things to do +### Doc Conversion - We don't support batch invoicing (It is used only for batch modifications) - We don't support modification of invoices - We don't support fiscal representatives @@ -12,8 +13,7 @@ The invoice data content of the data report must be embedded, encoded in BASE64 - In the VAT rate we are missing the vat amount mismatch field (used when VAT has been charged under section 11 or 14) - We don't support refund product charges (Field Product Fee Summary in the Invoice) -- For each requestID, it is needed to have a new id for each request (non-repeat). For the moment, this is done just at random but there is a small probability that the request IDs match. - +### API Connection - Nav supports 100 invoice creation/modification in the same request. For the moment, we only support 1 invoice at each request. If this is changed, it should also be changed in status.go, as now status only answers with the errors/warning of the first invoice. ## Invoice issuing order diff --git a/go.mod b/go.mod index 125453b..6286366 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,14 @@ require ( github.com/go-resty/resty/v2 v2.14.0 github.com/invopop/gobl v0.113.0 github.com/joho/godotenv v1.5.1 - github.com/orcaman/concurrent-map/v2 v2.0.1 + github.com/lestrrat-go/libxml2 v0.0.0-20240521004304-a75c203ac627 github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.25.0 ) require ( cloud.google.com/go v0.110.2 // indirect + github.com/Masterminds/semver/v3 v3.2.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect @@ -21,7 +22,9 @@ require ( github.com/invopop/jsonschema v0.12.0 // indirect github.com/invopop/validation v0.7.0 // indirect github.com/invopop/yaml v0.3.1 // indirect + github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect diff --git a/go.sum b/go.sum index 27ca1ff..91ae90c 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,6 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/invopop/gobl v0.113.0 h1:Ap3Kq5bEkNWlVt8zsn2dkA4dhU5skCoMkOTfTHA33DA= -github.com/invopop/gobl v0.113.0/go.mod h1:lnlUK1cwjla/EPdxH1O7hKSSBv58DNWLV4/lOCv1vlQ= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/validation v0.7.0 h1:NBPLqvYGmLZLQuk5jh0PbaBBetJW7f2VEk/BTWJkGBU= @@ -29,12 +27,14 @@ github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/lestrrat-go/libxml2 v0.0.0-20240521004304-a75c203ac627 h1:M7EEuhGDmn9bWSnap5mzVlg8pmVucmLytCjVjm+H4QU= +github.com/lestrrat-go/libxml2 v0.0.0-20240521004304-a75c203ac627/go.mod h1:/0MMipmS+5SMXCSkulsvJwYmddKI4IL5tVy6AZMo9n0= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= -github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 h1:wD1IWQwAhdWclCwaf6DdzgCAe9Bfz1M+4AHRd7N786Y= @@ -119,6 +119,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/xmlpath.v1 v1.0.0-20140413065638-a146725ea6e7 h1:zibSPXbkfB1Dwl76rJgLa68xcdHu42qmFTe6vAnU4wA= +gopkg.in/xmlpath.v1 v1.0.0-20140413065638-a146725ea6e7/go.mod h1:wo0SW5T6XqIKCCAge330Cd5sm+7VI6v85OrQHIk50KM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/doc/customer.go b/internal/doc/customer.go index c01c80a..87a72f8 100644 --- a/internal/doc/customer.go +++ b/internal/doc/customer.go @@ -21,9 +21,9 @@ type VatData struct { } type CustomerTaxNumber struct { - TaxPayerID string `xml:"taxpayerId"` - VatCode string `xml:"vatCode,omitempty"` - CountyCode string `xml:"countyCode,omitempty"` + TaxPayerID string `xml:"base:taxpayerId"` + VatCode string `xml:"base:vatCode,omitempty"` + CountyCode string `xml:"base:countyCode,omitempty"` GroupMemberTaxNumber *TaxNumber `xml:"groupMemberTaxNumber,omitempty"` } diff --git a/internal/doc/doc.go b/internal/doc/doc.go index 8fc5752..4c75f38 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -11,6 +11,14 @@ import ( */ +const ( + XMNLSDATA = "http://schemas.nav.gov.hu/OSA/3.0/data" + XMNLSCOMMON = "http://schemas.nav.gov.hu/NTCA/1.0/common" + XMNLBASE = "http://schemas.nav.gov.hu/OSA/3.0/base" + XMNLXSI = "http://www.w3.org/2001/XMLSchema-instance" + XSIDataSchema = "http://schemas.nav.gov.hu/OSA/3.0/data invoiceData.xsd" +) + // Standard error responses. var ( ErrNotHungarian = newValidationError("only hungarian invoices are supported") @@ -35,12 +43,12 @@ func newValidationError(text string) error { } type Document struct { - XMLName xml.Name `xml:"InvoiceData"` - XMLNS string `xml:"xmlns,attr"` - //XMLNSXsi string `xml:"xmlns:xsi,attr"` - //XSISchema string `xml:"xsi:schemaLocation,attr"` - //XMLNSCommon string `xml:"xmlns:common,attr"` - //XMLNSBase string `xml:"xmlns:base,attr"` + XMLName xml.Name `xml:"InvoiceData"` + XMLNS string `xml:"xmlns,attr"` + XMLNSXsi string `xml:"xmlns:xsi,attr"` + XSISchema string `xml:"xsi:schemaLocation,attr"` + XMLNSCommon string `xml:"xmlns:common,attr"` + XMLNSBase string `xml:"xmlns:base,attr"` InvoiceNumber string `xml:"invoiceNumber"` InvoiceIssueDate string `xml:"invoiceIssueDate"` CompletenessIndicator bool `xml:"completenessIndicator"` // Indicates whether the data report is the invoice itself @@ -50,11 +58,11 @@ type Document struct { // Convert it to XML before returning func NewDocument(inv *bill.Invoice) *Document { d := new(Document) - d.XMLNS = "http://schemas.nav.gov.hu/OSA/3.0/data" - //d.XMLNSXsi = "http://www.w3.org/2001/XMLSchema-instance" - //d.XSISchema = "http://schemas.nav.gov.hu/OSA/3.0/data invoiceData.xsd" - //d.XMLNSCommon = "http://schemas.nav.gov.hu/NTCA/1.0/common" - //d.XMLNSBase = "http://schemas.nav.gov.hu/OSA/3.0/base" + d.XMLNS = XMNLSDATA + d.XMLNSXsi = XMNLXSI + d.XSISchema = XSIDataSchema + d.XMLNSCommon = XMNLSCOMMON + d.XMLNSBase = XMNLBASE d.InvoiceNumber = inv.Code d.InvoiceIssueDate = inv.IssueDate.String() d.CompletenessIndicator = false diff --git a/internal/doc/doc_test.go b/internal/doc/doc_test.go new file mode 100644 index 0000000..f9adc24 --- /dev/null +++ b/internal/doc/doc_test.go @@ -0,0 +1,91 @@ +package doc + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "os" + "testing" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/bill" + "github.com/lestrrat-go/libxml2" + "github.com/lestrrat-go/libxml2/xsd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewDocument(t *testing.T) { + // Read the test invoice JSON file + data, err := os.ReadFile("../../test/data/invoice_test.json") + require.NoError(t, err, "Failed to read test invoice file") + + // Unmarshal the JSON into a gobl.Envelope + env := new(gobl.Envelope) + err = json.Unmarshal(data, env) + require.NoError(t, err, "Failed to unmarshal test invoice JSON") + + // Extract the invoice from the envelope + inv, ok := env.Extract().(*bill.Invoice) + require.True(t, ok, "Failed to extract invoice from envelope") + + // Call the NewDocument function + doc := NewDocument(inv) + + xmlData, err := xml.MarshalIndent(doc, "", " ") + if err != nil { + fmt.Printf("Error marshalling to XML: %v\n", err) + return + } + + err = os.WriteFile("../../test/data/out/output.xml", xmlData, 0644) + if err != nil { + fmt.Println("Error writing XML to file:", err) + return + } + + // 2. Load XSD schema + xsdContent, err := os.ReadFile("../../test/schemas/invoiceData.xsd") + if err != nil { + fmt.Println("Error reading XSD file:", err) + return + } + + schema, err := xsd.Parse(xsdContent) + if err != nil { + fmt.Println("Error parsing XSD:", err) + return + } + defer schema.Free() + + // 3. Parse XML + docXML, err := libxml2.ParseString(string(xmlData)) + if err != nil { + fmt.Println("Error parsing XML:", err) + return + } + defer docXML.Free() + + // 4. Validate XML against schema + if err := schema.Validate(docXML); err != nil { + fmt.Println("Validation error:", err) + } else { + fmt.Println("XML is valid according to the schema") + } + + fmt.Println(string(xmlData)) + + // Assert the expected values + assert.Equal(t, XMNLSDATA, doc.XMLNS, "Unexpected XMLNS value") + assert.Equal(t, XMNLXSI, doc.XMLNSXsi, "Unexpected XMLNSXsi value") + assert.Equal(t, XSIDataSchema, doc.XSISchema, "Unexpected XSISchema value") + assert.Equal(t, XMNLSCOMMON, doc.XMLNSCommon, "Unexpected XMLNSCommon value") + assert.Equal(t, XMNLBASE, doc.XMLNSBase, "Unexpected XMLNSBase value") + assert.Equal(t, inv.Code, doc.InvoiceNumber, "Unexpected InvoiceNumber value") + assert.Equal(t, inv.IssueDate.String(), doc.InvoiceIssueDate, "Unexpected InvoiceIssueDate value") + assert.False(t, doc.CompletenessIndicator, "Unexpected CompletenessIndicator value") + + // Assert that InvoiceMain is not nil + assert.NotNil(t, doc.InvoiceMain, "InvoiceMain should not be nil") + +} diff --git a/internal/doc/invoice.go b/internal/doc/invoice.go index 0ac6b1b..bb5e6c3 100644 --- a/internal/doc/invoice.go +++ b/internal/doc/invoice.go @@ -10,7 +10,7 @@ type InvoiceMain struct { } type Invoice struct { - //InvoiceReference InvoiceReference `xml:"invoiceReference,omitempty"` // Used for invoice modification + //InvoiceReference InvoiceReference `xml:"invoiceReference,omitempty"` // Used for invoice modification (reference other invoice) InvoiceHead *InvoiceHead `xml:"invoiceHead"` InvoiceLines *InvoiceLines `xml:"invoiceLines,omitempty"` //ProductFeeSummary ProductFeeSummary `xml:"productFeeSummary,omitempty"` @@ -29,16 +29,16 @@ func NewInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { return nil, err } - //invoiceSummary, err := NewInvoiceSummary(inv) + invoiceSummary, err := NewInvoiceSummary(inv) if err != nil { return nil, err } return &InvoiceMain{ Invoice: &Invoice{ - InvoiceHead: invoiceHead, - InvoiceLines: invoiceLines, - //InvoiceSummary: invoiceSummary, + InvoiceHead: invoiceHead, + InvoiceLines: invoiceLines, + InvoiceSummary: invoiceSummary, }, }, nil } diff --git a/internal/doc/lines.go b/internal/doc/lines.go index 0e27363..d264aea 100644 --- a/internal/doc/lines.go +++ b/internal/doc/lines.go @@ -22,11 +22,11 @@ type Line struct { LineExpressionIndicator bool `xml:"lineExpressionIndicator"` // true if the quantity unit of the item can be expressed as a natural unit of measurement LineNatureIndicator string `xml:"lineNatureIndicator,omitempty"` // Denotes sale of product or service LineDescription string `xml:"lineDescription,omitempty"` - Quantity float64 `xml:"quantity,omitempty"` + Quantity string `xml:"quantity,omitempty"` UnitOfMeasure string `xml:"unitOfMeasure,omitempty"` UnitOfMeasureOwn string `xml:"unitOfMeasureOwn,omitempty"` // Own quantity unit - UnitPrice float64 `xml:"unitPrice,omitempty"` - UnitPriceHUF float64 `xml:"unitPriceHUF,omitempty"` + UnitPrice string `xml:"unitPrice,omitempty"` + UnitPriceHUF string `xml:"unitPriceHUF,omitempty"` LineDiscountData *LineDiscountData `xml:"lineDiscountData,omitempty"` LineAmountsNormal *LineAmountsNormal `xml:"lineAmountsNormal,omitempty"` // For normal or aggregate invoices LineAmountsSimplified *LineAmountsSimplified `xml:"lineAmountsSimplified,omitempty"` // For simplified invoices @@ -56,9 +56,9 @@ type ProductCode struct { } type LineDiscountData struct { - DiscountDescription string `xml:"discountDescription"` - DiscountValue float64 `xml:"discountValue"` - DiscountRate float64 `xml:"discountRate"` + DiscountDescription string `xml:"discountDescription"` + DiscountValue string `xml:"discountValue"` + DiscountRate string `xml:"discountRate"` } type LineAmountsNormal struct { @@ -69,25 +69,25 @@ type LineAmountsNormal struct { } type LineNetAmountData struct { - LineNetAmount float64 `xml:"lineNetAmount"` - LineNetAmountHUF float64 `xml:"lineNetAmountHUF"` + LineNetAmount string `xml:"lineNetAmount"` + LineNetAmountHUF string `xml:"lineNetAmountHUF"` } type LineVatData struct { - LineVatAmount float64 `xml:"lineVatAmount"` - LineVatAmountHUF float64 `xml:"lineVatAmountHUF"` + LineVatAmount string `xml:"lineVatAmount"` + LineVatAmountHUF string `xml:"lineVatAmountHUF"` } // LineGrossAmountData is the Net amount + VAT amount (Not mandatory) type LineGrossAmountData struct { - LineGrossAmount float64 `xml:"lineGrossAmount"` - LineGrossAmountHUF float64 `xml:"lineGrossAmountHUF"` + LineGrossAmount string `xml:"lineGrossAmount"` + LineGrossAmountHUF string `xml:"lineGrossAmountHUF"` } type LineAmountsSimplified struct { LineVatRate *VatRate `xml:"lineVatRate"` - LineGrossAmountSimplified float64 `xml:"lineGrossAmountSimplified"` //This amount is the net amount of the normal line - LineGrossAmountSimplifiedHUF float64 `xml:"lineGrossAmountSimplifiedHUF"` + LineGrossAmountSimplified string `xml:"lineGrossAmountSimplified"` //This amount is the net amount of the normal line + LineGrossAmountSimplifiedHUF string `xml:"lineGrossAmountSimplifiedHUF"` } var codeCategories = []string{ @@ -125,9 +125,9 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { LineNumber: line.Index, LineExpressionIndicator: false, LineDescription: line.Item.Name, - UnitPrice: line.Item.Price.Float64(), - UnitPriceHUF: amountToHUF(line.Item.Price, rate).Float64(), - Quantity: line.Quantity.Float64(), + UnitPrice: line.Item.Price.String(), + UnitPriceHUF: amountToHUF(line.Item.Price, rate).String(), + Quantity: line.Quantity.String(), } if line.Item.Identities != nil { @@ -160,10 +160,12 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { if line.Discounts != nil { discount := &LineDiscountData{} discount.DiscountDescription = "" + discountValue := 0.0 for _, dis := range line.Discounts { discount.DiscountDescription += dis.Reason + ". " - discount.DiscountValue += dis.Amount.Float64() + discountValue += dis.Amount.Float64() } + discount.DiscountValue = num.AmountFromFloat64(discountValue, 2).String() lineNav.LineDiscountData = discount } @@ -172,33 +174,21 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { if info.simplifiedInvoice { vatAmount := line.Total.Multiply(vatCombo.Percent.Amount()) lineNav.LineAmountsSimplified = &LineAmountsSimplified{ - LineVatRate: &VatRate{VatContent: vatAmount.Rescale(4).Float64()}, - LineGrossAmountSimplified: line.Total.Rescale(2).Float64(), - LineGrossAmountSimplifiedHUF: amountToHUF(line.Total, rate).Float64(), + LineVatRate: &VatRate{VatContent: vatAmount.Rescale(2).String()}, + LineGrossAmountSimplified: line.Total.Rescale(2).String(), + LineGrossAmountSimplifiedHUF: amountToHUF(line.Total, rate).String(), } } else { vatRate, err := NewVatRate(vatCombo, info) if err != nil { return nil, err } - vatAmount := num.AmountZero - if vatCombo.Percent != nil { - vatAmount = line.Total.Multiply(vatCombo.Percent.Amount()).Rescale(2) - } lineNav.LineAmountsNormal = &LineAmountsNormal{ LineNetAmountData: &LineNetAmountData{ - LineNetAmount: line.Total.Rescale(2).Float64(), - LineNetAmountHUF: amountToHUF(line.Total, rate).Float64(), + LineNetAmount: line.Total.Rescale(2).String(), + LineNetAmountHUF: amountToHUF(line.Total, rate).String(), }, LineVatRate: vatRate, - LineVatData: &LineVatData{ - LineVatAmount: vatAmount.Float64(), - LineVatAmountHUF: amountToHUF(vatAmount, rate).Float64(), - }, - LineGrossAmountData: &LineGrossAmountData{ - LineGrossAmount: line.Total.Add(vatAmount).Rescale(2).Float64(), - LineGrossAmountHUF: amountToHUF(line.Total.Add(vatAmount), rate).Float64(), - }, } } } diff --git a/internal/doc/lines_test.go b/internal/doc/lines_test.go index d250a92..384a5c7 100644 --- a/internal/doc/lines_test.go +++ b/internal/doc/lines_test.go @@ -75,7 +75,6 @@ func TestNewInvoiceLines(t *testing.T) { // Check VAT and Amounts assert.NotNil(t, line.LineAmountsNormal) assert.Equal(t, 195.00, line.LineAmountsNormal.LineNetAmountData.LineNetAmount) - assert.Equal(t, 247.65, line.LineAmountsNormal.LineGrossAmountData.LineGrossAmount) // Assuming 27% VAT } func TestNewLine_SimplifiedInvoice(t *testing.T) { diff --git a/internal/doc/summary.go b/internal/doc/summary.go index b3009e7..195ed11 100644 --- a/internal/doc/summary.go +++ b/internal/doc/summary.go @@ -1,5 +1,11 @@ package doc +import ( + "github.com/invopop/gobl/bill" + "github.com/invopop/gobl/num" + "github.com/invopop/gobl/tax" +) + // Depends wether the invoice is simplified or not type InvoiceSummary struct { SummaryNormal *SummaryNormal `xml:"summaryNormal,omitempty"` @@ -32,21 +38,25 @@ type VatRateVatData struct { VatRateVatAmountHUF string `xml:"vatRateVatAmountHUF"` } -/*func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex float64) *SummaryByVatRate { +func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex float64) (*SummaryByVatRate, error) { + vatRate, err := NewVatRate(rate, info) + if err != nil { + return nil, err + } return &SummaryByVatRate{ - VatRate: NewVatRate(rate, info), + VatRate: vatRate, VatRateNetData: &VatRateNetData{ VatRateNetAmount: rate.Base.Rescale(2).String(), - VatRateNetAmountHUF: amountToHUF(rate.Base, ex), + VatRateNetAmountHUF: amountToHUF(rate.Base, ex).String(), }, VatRateVatData: &VatRateVatData{ VatRateVatAmount: rate.Amount.Rescale(2).String(), - VatRateVatAmountHUF: amountToHUF(rate.Amount, ex), + VatRateVatAmountHUF: amountToHUF(rate.Amount, ex).String(), }, - } -}*/ + }, nil +} -/*func NewInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { +func NewInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { vat := inv.Totals.Taxes.Category(tax.CategoryVAT) totalVat := num.MakeAmount(0, 5) summaryVat := []*SummaryByVatRate{} @@ -57,7 +67,11 @@ type VatRateVatData struct { return nil, err } for _, rate := range vat.Rates { - summaryVat = append(summaryVat, newSummaryByVatRate(rate, taxInfo, ex)) + summary, err := newSummaryByVatRate(rate, taxInfo, ex) + if err != nil { + return nil, err + } + summaryVat = append(summaryVat, summary) totalVat = totalVat.Add(rate.Amount) } @@ -65,10 +79,10 @@ type VatRateVatData struct { SummaryNormal: &SummaryNormal{ SummaryByVatRate: summaryVat, InvoiceNetAmount: inv.Totals.Total.Rescale(2).String(), - InvoiceNetAmountHUF: amountToHUF(inv.Totals.Total, ex), + InvoiceNetAmountHUF: amountToHUF(inv.Totals.Total, ex).String(), InvoiceVatAmount: totalVat.Rescale(2).String(), - InvoiceVatAmountHUF: amountToHUF(totalVat, ex), + InvoiceVatAmountHUF: amountToHUF(totalVat, ex).String(), }, }, nil -}*/ +} diff --git a/internal/doc/supplier.go b/internal/doc/supplier.go index 7f2ba27..34e209a 100644 --- a/internal/doc/supplier.go +++ b/internal/doc/supplier.go @@ -1,7 +1,6 @@ package doc import ( - "github.com/invopop/gobl/l10n" "github.com/invopop/gobl/org" ) @@ -17,10 +16,6 @@ type SupplierInfo struct { } func NewSupplierInfo(supplier *org.Party) (*SupplierInfo, error) { - taxId := supplier.TaxID - if taxId.Country != l10n.HU.Tax() { - return nil, ErrNotHungarian - } taxNumber, groupNumber, err := NewTaxNumber(supplier) if err != nil { return nil, err diff --git a/internal/doc/supplier_test.go b/internal/doc/supplier_test.go index 1843c0e..fb9e0d3 100644 --- a/internal/doc/supplier_test.go +++ b/internal/doc/supplier_test.go @@ -38,20 +38,6 @@ func TestNewSupplierInfo(t *testing.T) { assert.Equal(t, "Test Supplier", supplierInfo.SupplierName) assert.NotNil(t, supplierInfo.SupplierAddress) - // Test case 2: Non-Hungarian supplier - nonHUSupplier := &org.Party{ - TaxID: &tax.Identity{ - Country: l10n.GB.Tax(), // GB for Great Britain - Code: "87654321", - }, - Name: "Non-Hungarian Supplier", - } - - supplierInfo, err = NewSupplierInfo(nonHUSupplier) - require.Error(t, err) - assert.Nil(t, supplierInfo) - assert.Equal(t, ErrNotHungarian, err) - supplier = &org.Party{ TaxID: &tax.Identity{ Country: l10n.HU.Tax(), diff --git a/internal/doc/taxnumber.go b/internal/doc/taxnumber.go index ce640a7..9c651ce 100644 --- a/internal/doc/taxnumber.go +++ b/internal/doc/taxnumber.go @@ -21,9 +21,6 @@ func NewTaxNumber(party *org.Party) (*TaxNumber, *TaxNumber, error) { if len(taxID.Code) == 11 { if taxID.Code.String()[8:9] == "5" { groupMemberCode := party.Identities[0].Code.String() - if len(groupMemberCode) != 11 || groupMemberCode[8:9] != "4" { - return nil, nil, ErrInvalidGroupMemberCode - } return NewHungarianTaxNumber(taxID.Code.String()), NewHungarianTaxNumber(groupMemberCode), nil } diff --git a/internal/doc/taxnumber_test.go b/internal/doc/taxnumber_test.go index 4b82fa6..2fe77bd 100644 --- a/internal/doc/taxnumber_test.go +++ b/internal/doc/taxnumber_test.go @@ -54,21 +54,6 @@ func TestNewTaxNumber(t *testing.T) { }, expectedErr: nil, }, - { - name: "Hungarian TaxID with VatCode 5 and invalid group member code", - party: &org.Party{ - TaxID: &tax.Identity{ - Country: l10n.HU.Tax(), - Code: "12345678501", - }, - Identities: []*org.Identity{ - {Code: "12345678303"}, - }, - }, - expectedMain: nil, - expectedGroup: nil, - expectedErr: ErrInvalidGroupMemberCode, - }, { name: "Hungarian TaxID with other VatCode", party: &org.Party{ diff --git a/internal/doc/vatrate.go b/internal/doc/vatrate.go index 91ac639..ddf51f2 100644 --- a/internal/doc/vatrate.go +++ b/internal/doc/vatrate.go @@ -2,16 +2,14 @@ package doc import ( "github.com/invopop/gobl/bill" - //"github.com/invopop/gobl/regimes/hu" + "github.com/invopop/gobl/regimes/hu" "github.com/invopop/gobl/tax" ) -//"github.com/invopop/gobl/regimes/hu" - // Vat Rate may contain exactly one of the 8 possible fields type VatRate struct { - VatPercentage float64 `xml:"vatPercentage,omitempty"` - VatContent float64 `xml:"vatContent,omitempty"` //VatContent is only for simplified invoices + VatPercentage string `xml:"vatPercentage,omitempty"` + VatContent string `xml:"vatContent,omitempty"` //VatContent is only for simplified invoices VatExemption *DetailedReason `xml:"vatExemption,omitempty"` VatOutOfScope *DetailedReason `xml:"vatOutOfScope,omitempty"` VatDomesticReverseCharge bool `xml:"vatDomesticReverseCharge,omitempty"` @@ -53,12 +51,12 @@ func NewVatRate(obj any, info *taxInfo) (*VatRate, error) { func newVatRateTotal(rate *tax.RateTotal, info *taxInfo) (*VatRate, error) { // First if it is not exent or simplified invoice we can return the percentage if rate.Percent != nil { - return &VatRate{VatPercentage: rate.Percent.Amount().Rescale(4).Float64()}, nil + return &VatRate{VatPercentage: rate.Percent.Base().String()}, nil } // If it is a simplified invoice we can return the content if info.simplifiedInvoice { - return &VatRate{VatContent: rate.Amount.Rescale(4).Float64()}, nil + return &VatRate{VatContent: rate.Amount.Rescale(2).String()}, nil } // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode @@ -106,7 +104,7 @@ func newVatRateTotal(rate *tax.RateTotal, info *taxInfo) (*VatRate, error) { func newVatRateCombo(c *tax.Combo, info *taxInfo) (*VatRate, error) { // First if it is not exent or simplified invoice we can return the percentage if c.Percent != nil { - return &VatRate{VatPercentage: c.Percent.Amount().Rescale(4).Float64()}, nil + return &VatRate{VatPercentage: c.Percent.Base().String()}, nil } // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode @@ -148,7 +146,6 @@ func newVatRateCombo(c *tax.Combo, info *taxInfo) (*VatRate, error) { return nil, ErrNoVatRateField } -// Until PR approved in regimes this wont work func newTaxInfo(inv *bill.Invoice) *taxInfo { info := &taxInfo{} if inv.Tax != nil { @@ -156,15 +153,15 @@ func newTaxInfo(inv *bill.Invoice) *taxInfo { switch scheme { case tax.TagSimplified: info.simplifiedInvoice = true - case "domestic-reverse-charge": //case hu.TagDomesticReverseCharge: + case hu.TagDomesticReverseCharge: info.domesticReverseCharge = true - case "travel-agency": //hu.TagTravelAgency: + case hu.TagTravelAgency: info.travelAgency = true - case "second-hand": //hu.TagSecondHand: + case hu.TagSecondHand: info.secondHand = true - case "art": //hu.TagArt: + case hu.TagArt: info.art = true - case "antiques": //hu.TagAntique: + case hu.TagAntiques: info.antique = true } } diff --git a/internal/doc/vatrate_test.go b/internal/doc/vatrate_test.go index 33ecf61..21a511d 100644 --- a/internal/doc/vatrate_test.go +++ b/internal/doc/vatrate_test.go @@ -23,7 +23,7 @@ func TestNewVatRate(t *testing.T) { }, info: &taxInfo{}, expected: &VatRate{ - VatPercentage: 0.27, + VatPercentage: "0.27", }, expectErr: false, }, @@ -34,7 +34,7 @@ func TestNewVatRate(t *testing.T) { }, info: &taxInfo{simplifiedInvoice: true}, expected: &VatRate{ - VatContent: 100, + VatContent: "100.00", }, expectErr: false, }, diff --git a/internal/gateways/status_test.go b/internal/gateways/status_test.go index ccf8aa3..10b92f8 100644 --- a/internal/gateways/status_test.go +++ b/internal/gateways/status_test.go @@ -39,7 +39,7 @@ func TestQueryTransactionStatus(t *testing.T) { client := New(user, software, Environment("testing")) - result, err := client.GetStatus("4OYE2J5GEOGWKMYV") + result, err := client.GetStatus("4P0TNGVKJM2HV0C3") // Assert the results assert.NoError(t, err) diff --git a/nav_test.go b/nav_test.go index 5cc3d5f..6f415d6 100644 --- a/nav_test.go +++ b/nav_test.go @@ -41,7 +41,7 @@ func TestReportInvoice(t *testing.T) { navClient := NewNav(user, software, InTesting()) - xmlContent, err := os.ReadFile("examples/example.xml") + xmlContent, err := os.ReadFile("test/data/out/output.xml") if err != nil { t.Fatalf("Failed to read sample invoice file: %v", err) } @@ -51,6 +51,8 @@ func TestReportInvoice(t *testing.T) { transactionId, err := navClient.ReportInvoice(encodedInvoice) + fmt.Println("Transaction ID: ", transactionId) + // Assert the result if err != nil { t.Errorf("ReportInvoice returned an unexpected error: %v", err) diff --git a/test/data/invoice_test.json b/test/data/invoice_test.json new file mode 100644 index 0000000..2c65a17 --- /dev/null +++ b/test/data/invoice_test.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "9b62fd40-3b38-11ee-be56-0242ac120003", + "dig": { + "alg": "sha256", + "val": "fb3e81ee5d0964fa423dcfb62309a7a5c5150dc62cdd81427a68a2c85e893a66" + }, + "draft": true + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "type": "standard", + "series": "SAMPLE", + "code": "001", + "issue_date": "2024-02-13", + "currency": "HUF", + "supplier": { + "name": "Provide One Kft.", + "tax_id": { + "country": "HU", + "code": "12345678" + }, + "people": [ + { + "name": { + "given": "János", + "surname": "Kovács" + } + } + ], + "addresses": [ + { + "num": "16", + "street": "Andrássy út", + "locality": "Budapest", + "code": "1061", + "country": "HU" + } + ], + "emails": [ + { + "addr": "szamlazas@example.com" + } + ], + "telephones": [ + { + "num": "+36100200300" + } + ] + }, + "customer": { + "name": "Minta Fogyasztó", + "tax_id": { + "country": "HU", + "code": "87654321" + }, + "addresses": [ + { + "num": "25", + "street": "Váci utca", + "locality": "Budapest", + "code": "1052", + "country": "HU" + } + ], + "emails": [ + { + "addr": "email@minta.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Fejlesztési szolgáltatások", + "price": "30000.00", + "unit": "h" + }, + "sum": "600000.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "27%" + } + ], + "total": "600000.00" + } + ], + "ordering": { + "code": "XR-2024-2" + }, + "payment": { + "terms": { + "detail": "lorem ipsum" + } + }, + "totals": { + "sum": "600000.00", + "total": "600000.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "600000.00", + "percent": "27%", + "amount": "162000.00" + } + ], + "amount": "162000.00" + } + ], + "sum": "162000.00" + }, + "tax": "162000.00", + "total_with_tax": "762000.00", + "payable": "762000.00" + } + } +} \ No newline at end of file diff --git a/test/data/out/output.xml b/test/data/out/output.xml new file mode 100644 index 0000000..aaab409 --- /dev/null +++ b/test/data/out/output.xml @@ -0,0 +1,95 @@ + + 001 + 2024-02-13 + false + + + + + + 12345678 + + Provide One Kft. + + + HU + 1061 + Budapest + Andrássy út + utca + 16 + + + + + DOMESTIC + + + 87654321 + + + Minta Fogyasztó + + + HU + 1052 + Budapest + Váci utca + utca + 25 + + + + + NORMAL + 2024-02-13 + HUF + 1 + EDI + + + + false + + 1 + true + Fejlesztési szolgáltatások + 20 + HOUR + 30000.00 + 30000.00 + + + 600000.00 + 600000.00 + + + 0.27 + + + + + + + + + 0.27 + + + 600000.00 + 600000.00 + + + 162000.00 + 162000.00 + + + 600000.00 + 600000.00 + 162000.00 + 162000.00 + + + + + \ No newline at end of file diff --git a/schemas/common.xsd b/test/schemas/common.xsd similarity index 100% rename from schemas/common.xsd rename to test/schemas/common.xsd diff --git a/schemas/invoiceBase.xsd b/test/schemas/invoiceBase.xsd similarity index 100% rename from schemas/invoiceBase.xsd rename to test/schemas/invoiceBase.xsd diff --git a/schemas/invoiceData.xsd b/test/schemas/invoiceData.xsd similarity index 97% rename from schemas/invoiceData.xsd rename to test/schemas/invoiceData.xsd index 3e6d047..b0f1276 100644 --- a/schemas/invoiceData.xsd +++ b/test/schemas/invoiceData.xsd @@ -6,8 +6,10 @@ # Version: v3.0 2020/11/23 --> - - + + + Vevő ÁFA szerinti státusz típusa From af09d89035bf2e6adcf022d65467f9ba4c54f5d0 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Thu, 29 Aug 2024 11:56:48 +0000 Subject: [PATCH 19/27] adding cli --- cmd/gobl.nav/convert.go | 76 +++++++++++++++++++++++ cmd/gobl.nav/main.go | 36 +++++++++++ cmd/gobl.nav/root.go | 56 +++++++++++++++++ cmd/gobl.nav/version.go | 29 +++++++++ go.mod | 3 + go.sum | 8 +++ internal/doc/address.go | 2 +- internal/doc/doc.go | 43 ++++++++++--- internal/doc/doc_test.go | 3 +- invoice_test.json | 126 +++++++++++++++++++++++++++++++++++++++ nav.go | 6 ++ 11 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 cmd/gobl.nav/convert.go create mode 100644 cmd/gobl.nav/main.go create mode 100644 cmd/gobl.nav/root.go create mode 100644 cmd/gobl.nav/version.go create mode 100644 invoice_test.json diff --git a/cmd/gobl.nav/convert.go b/cmd/gobl.nav/convert.go new file mode 100644 index 0000000..f7975aa --- /dev/null +++ b/cmd/gobl.nav/convert.go @@ -0,0 +1,76 @@ +// Package main provides the command line interface to the NAV package. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/invopop/gobl" + nav "github.com/invopop/gobl.hu-nav" + "github.com/invopop/gobl/bill" + "github.com/spf13/cobra" +) + +type convertOpts struct { + *rootOpts +} + +func convert(o *rootOpts) *convertOpts { + return &convertOpts{rootOpts: o} +} + +func (c *convertOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "convert [infile] [outfile]", + Short: "Convert a GOBL JSON into a NAV XML", + RunE: c.runE, + } + + return cmd +} + +func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { + input, err := openInput(cmd, args) + if err != nil { + return err + } + defer input.Close() // nolint:errcheck + + out, err := c.openOutput(cmd, args) + if err != nil { + return err + } + defer out.Close() // nolint:errcheck + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(input); err != nil { + return fmt.Errorf("reading input: %w", err) + } + + env := new(gobl.Envelope) + if err := json.Unmarshal(buf.Bytes(), env); err != nil { + return fmt.Errorf("unmarshaling gobl envelope: %w", err) + } + + inv, ok := env.Extract().(*bill.Invoice) + if !ok { + return fmt.Errorf("invalid type %T", env.Document) + } + + doc, err := nav.NewDocument(inv) + if err != nil { + panic(err) + } + + data, err := doc.BytesIndent() + if err != nil { + return fmt.Errorf("generating nav xml: %w", err) + } + + if _, err = out.Write(append(data, '\n')); err != nil { + return fmt.Errorf("writing nav xml: %w", err) + } + + return nil +} diff --git a/cmd/gobl.nav/main.go b/cmd/gobl.nav/main.go new file mode 100644 index 0000000..2287ec8 --- /dev/null +++ b/cmd/gobl.nav/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" +) + +// build data provided by goreleaser and mage setup +var ( + version = "dev" + date = "" +) + +func main() { + if err := run(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + return root().cmd().ExecuteContext(ctx) +} + +func inputFilename(args []string) string { + if len(args) > 0 && args[0] != "-" { + return args[0] + } + return "" +} diff --git a/cmd/gobl.nav/root.go b/cmd/gobl.nav/root.go new file mode 100644 index 0000000..6fd349f --- /dev/null +++ b/cmd/gobl.nav/root.go @@ -0,0 +1,56 @@ +package main + +import ( + "io" + "os" + + "github.com/spf13/cobra" +) + +type rootOpts struct { +} + +func root() *rootOpts { + return &rootOpts{} +} + +func (o *rootOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "gobl.nav", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.AddCommand(versionCmd()) + cmd.AddCommand(convert(o).cmd()) + + return cmd +} + +func (o *rootOpts) outputFilename(args []string) string { + if len(args) >= 2 && args[1] != "-" { + return args[1] + } + return "" +} + +func openInput(cmd *cobra.Command, args []string) (io.ReadCloser, error) { + if inFile := inputFilename(args); inFile != "" { + return os.Open(inFile) + } + return io.NopCloser(cmd.InOrStdin()), nil +} + +func (o *rootOpts) openOutput(cmd *cobra.Command, args []string) (io.WriteCloser, error) { + if outFile := o.outputFilename(args); outFile != "" { + flags := os.O_CREATE | os.O_WRONLY + return os.OpenFile(outFile, flags, os.ModePerm) + } + return writeCloser{cmd.OutOrStdout()}, nil +} + +type writeCloser struct { + io.Writer +} + +func (writeCloser) Close() error { return nil } diff --git a/cmd/gobl.nav/version.go b/cmd/gobl.nav/version.go new file mode 100644 index 0000000..31d11ab --- /dev/null +++ b/cmd/gobl.nav/version.go @@ -0,0 +1,29 @@ +package main + +import ( + "encoding/json" + + "github.com/invopop/gobl" + "github.com/spf13/cobra" +) + +var versionOutput = struct { + Version string `json:"version"` + GOBL string `json:"gobl"` + Date string `json:"date,omitempty"` +}{ + Version: version, + GOBL: string(gobl.VERSION), + Date: date, +} + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + RunE: func(cmd *cobra.Command, _ []string) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", "\t") // always indent version + return enc.Encode(versionOutput) + }, + } +} diff --git a/go.mod b/go.mod index 6286366..96d11c5 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/invopop/gobl v0.113.0 github.com/joho/godotenv v1.5.1 github.com/lestrrat-go/libxml2 v0.0.0-20240521004304-a75c203ac627 + github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.25.0 ) @@ -19,6 +20,7 @@ require ( github.com/buger/jsonparser v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.12.0 // indirect github.com/invopop/validation v0.7.0 // indirect github.com/invopop/yaml v0.3.1 // indirect @@ -26,6 +28,7 @@ require ( github.com/mailru/easyjson v0.7.7 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect golang.org/x/net v0.27.0 // indirect diff --git a/go.sum b/go.sum index 91ae90c..a4e5bb4 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,7 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -18,6 +19,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI= github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/invopop/validation v0.7.0 h1:NBPLqvYGmLZLQuk5jh0PbaBBetJW7f2VEk/BTWJkGBU= @@ -37,6 +40,11 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 h1:wD1IWQwAhdWclCwaf6DdzgCAe9Bfz1M+4AHRd7N786Y= github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693/go.mod h1:6hSY48PjDm4UObWmGLyJE9DxYVKTgR9kbCspXXJEhcU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/doc/address.go b/internal/doc/address.go index e13180c..9601b92 100644 --- a/internal/doc/address.go +++ b/internal/doc/address.go @@ -64,7 +64,7 @@ func NewDetailedAddress(address *org.Address) *DetailedAddress { Building: address.Block, Floor: address.Floor, Door: address.Door, - PublicPlaceCategory: "utca", //address.StreetType, //Waiting for PR to be approved + PublicPlaceCategory: address.StreetType, } } diff --git a/internal/doc/doc.go b/internal/doc/doc.go index 4c75f38..d94b4b5 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -1,8 +1,10 @@ package doc import ( + "bytes" "encoding/xml" "errors" + "fmt" "github.com/invopop/gobl/bill" ) @@ -21,10 +23,8 @@ const ( // Standard error responses. var ( - ErrNotHungarian = newValidationError("only hungarian invoices are supported") - ErrNoExchangeRate = newValidationError("no exchange rate to HUF found") - ErrInvalidGroupMemberCode = newValidationError("invalid group member code") - ErrNoVatRateField = newValidationError("no vat rate field found") + ErrNoExchangeRate = newValidationError("no exchange rate to HUF found") + ErrNoVatRateField = newValidationError("no vat rate field found") ) // ValidationError is a simple wrapper around validation errors (that should not be retried) as opposed @@ -56,7 +56,7 @@ type Document struct { } // Convert it to XML before returning -func NewDocument(inv *bill.Invoice) *Document { +func NewDocument(inv *bill.Invoice) (*Document, error) { d := new(Document) d.XMLNS = XMNLSDATA d.XMLNSXsi = XMNLXSI @@ -68,8 +68,37 @@ func NewDocument(inv *bill.Invoice) *Document { d.CompletenessIndicator = false main, err := NewInvoiceMain(inv) if err != nil { - panic(err) + return nil, err } d.InvoiceMain = main - return d + return d, nil +} + +// BytesIndent returns the indented XML document bytes +func (doc *Document) BytesIndent() ([]byte, error) { + return toBytesIndent(doc) +} + +func toBytesIndent(doc any) ([]byte, error) { + buf, err := buffer(doc, xml.Header, true) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func buffer(doc any, base string, indent bool) (*bytes.Buffer, error) { + buf := bytes.NewBufferString(base) + + enc := xml.NewEncoder(buf) + if indent { + enc.Indent("", " ") + } + + if err := enc.Encode(doc); err != nil { + return nil, fmt.Errorf("encoding document: %w", err) + } + + return buf, nil } diff --git a/internal/doc/doc_test.go b/internal/doc/doc_test.go index f9adc24..7892076 100644 --- a/internal/doc/doc_test.go +++ b/internal/doc/doc_test.go @@ -30,7 +30,8 @@ func TestNewDocument(t *testing.T) { require.True(t, ok, "Failed to extract invoice from envelope") // Call the NewDocument function - doc := NewDocument(inv) + doc, err := NewDocument(inv) + require.NoError(t, err, "Failed to create new document") xmlData, err := xml.MarshalIndent(doc, "", " ") if err != nil { diff --git a/invoice_test.json b/invoice_test.json new file mode 100644 index 0000000..2c65a17 --- /dev/null +++ b/invoice_test.json @@ -0,0 +1,126 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "9b62fd40-3b38-11ee-be56-0242ac120003", + "dig": { + "alg": "sha256", + "val": "fb3e81ee5d0964fa423dcfb62309a7a5c5150dc62cdd81427a68a2c85e893a66" + }, + "draft": true + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "type": "standard", + "series": "SAMPLE", + "code": "001", + "issue_date": "2024-02-13", + "currency": "HUF", + "supplier": { + "name": "Provide One Kft.", + "tax_id": { + "country": "HU", + "code": "12345678" + }, + "people": [ + { + "name": { + "given": "János", + "surname": "Kovács" + } + } + ], + "addresses": [ + { + "num": "16", + "street": "Andrássy út", + "locality": "Budapest", + "code": "1061", + "country": "HU" + } + ], + "emails": [ + { + "addr": "szamlazas@example.com" + } + ], + "telephones": [ + { + "num": "+36100200300" + } + ] + }, + "customer": { + "name": "Minta Fogyasztó", + "tax_id": { + "country": "HU", + "code": "87654321" + }, + "addresses": [ + { + "num": "25", + "street": "Váci utca", + "locality": "Budapest", + "code": "1052", + "country": "HU" + } + ], + "emails": [ + { + "addr": "email@minta.com" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Fejlesztési szolgáltatások", + "price": "30000.00", + "unit": "h" + }, + "sum": "600000.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "27%" + } + ], + "total": "600000.00" + } + ], + "ordering": { + "code": "XR-2024-2" + }, + "payment": { + "terms": { + "detail": "lorem ipsum" + } + }, + "totals": { + "sum": "600000.00", + "total": "600000.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "600000.00", + "percent": "27%", + "amount": "162000.00" + } + ], + "amount": "162000.00" + } + ], + "sum": "162000.00" + }, + "tax": "162000.00", + "total_with_tax": "762000.00", + "payable": "762000.00" + } + } +} \ No newline at end of file diff --git a/nav.go b/nav.go index 43e7ea0..84292aa 100644 --- a/nav.go +++ b/nav.go @@ -1,7 +1,9 @@ package nav import ( + "github.com/invopop/gobl.hu-nav/internal/doc" "github.com/invopop/gobl.hu-nav/internal/gateways" + "github.com/invopop/gobl/bill" "github.com/invopop/gobl/tax" ) @@ -60,3 +62,7 @@ func NewSoftware(taxNumber tax.Identity, name string, operation string, version func NewUser(login string, password string, signKey string, exchangeKey string, taxNumber string) *gateways.User { return gateways.NewUser(login, password, signKey, exchangeKey, taxNumber) } + +func NewDocument(inv *bill.Invoice) (*doc.Document, error) { + return doc.NewDocument(inv) +} From 6a6ac87f0afe67226f271508fe97db987083a8ff Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Thu, 29 Aug 2024 17:52:09 +0000 Subject: [PATCH 20/27] Updating doc generation --- .github/workflows/lint.yaml | 27 ++++ .github/workflows/test.yaml | 24 +++ README.md | 148 +++++++++++++++--- cmd/gobl.nav/convert.go | 10 +- internal/doc/address.go | 18 +-- internal/doc/address_test.go | 2 +- internal/doc/customer.go | 17 +- internal/doc/customer_test.go | 2 +- internal/doc/detail.go | 21 +-- internal/doc/detail_test.go | 6 +- internal/doc/doc.go | 48 ++---- internal/doc/doc_test.go | 9 +- internal/doc/head.go | 8 +- internal/doc/invoice.go | 14 +- internal/doc/lines.go | 31 ++-- internal/doc/lines_test.go | 6 +- internal/doc/summary.go | 6 +- internal/doc/supplier.go | 13 +- internal/doc/supplier_test.go | 4 +- internal/doc/taxnumber.go | 18 +-- internal/doc/taxnumber_test.go | 2 +- internal/doc/vatrate.go | 18 ++- internal/doc/vatrate_test.go | 2 +- internal/gateways/invoice.go | 8 +- nav.go | 37 ++++- nav_test.go | 10 +- .../data/invoice-test.json | 0 test/data/invoice_test.json | 126 --------------- 28 files changed, 340 insertions(+), 295 deletions(-) create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/test.yaml rename invoice_test.json => test/data/invoice-test.json (100%) delete mode 100644 test/data/invoice_test.json diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..e5ffa87 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,27 @@ +name: Lint +on: + push: + tags: + - v* + branches: + - main + pull_request: +jobs: + lint: + name: golangci-lint + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version-file: "go.mod" + id: go + + - name: Lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.59.1 \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..6096228 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,24 @@ +name: Test Go +on: [push] +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Set up Go + uses: actions/setup-go@v1 + with: + go-version: "1.22.3" + id: go + + - name: Check out code + uses: actions/checkout@v2 + + - name: Install Dependencies + env: + GOPROXY: https://proxy.golang.org,direct + run: go mod download + + - name: Test + run: go test -tags unit -race ./... \ No newline at end of file diff --git a/README.md b/README.md index 87aec4c..32f2ca0 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,139 @@ # gobl.hu-nav -Convert GOBL into Hungarian NAV XML documents +Go library to convert [GOBL](https://github.com/invopop/gobl) invoices into TicketBAI declarations and send them to the Hungarian web services. -The invoice data content of the data report must be embedded, encoded in BASE64 format, in the ManageInvoiceRequest/invoiceoperations/invoiceOperation/InvoiceData element. +Copyright [Invopop Ltd.](https://invopop.com) 2023. Released publicly under the [Apache License v2.0](LICENSE). For commercial licenses please contact the [dev team at invopop](mailto:dev@invopop.com). For contributions to this library to be accepted, we will require you to accept transferring your copyright to Invopop Ltd. -## Limitations/Things to do +## Usage -### Doc Conversion -- We don't support batch invoicing (It is used only for batch modifications) -- We don't support modification of invoices -- We don't support fiscal representatives -- We don't support aggregate invoices -- In the VAT rate we are missing the vat amount mismatch field (used when VAT has been charged under section 11 or 14) -- We don't support refund product charges (Field Product Fee Summary in the Invoice) +### Go package + +#### Conversion +Usage of the XInvoice conversion library is quite straight forward. You must first have a GOBL Envelope including an invoice ready to convert. + +```go +package main + +import ( + "os" + + "github.com/invopop/gobl" + nav "github.com/invopop/gobl.hu-nav" +) + +func main() { + data, _ := os.ReadFile("./test/data/invoice-test.json") + + env := new(gobl.Envelope) + if err := json.Unmarshal(data, env); err != nil { + panic(err) + } + + // Prepare the CFDI document + doc, err := nav.NewDocument(env) + if err != nil { + panic(err) + } + + // Create the XML output + out, err := nav.BytesIndent(doc) + if err != nil { + panic(err) + } + + // TODO: do something with the output +} +``` + +#### Invoice Reporting + +Once the invoice is generated, it can be reported to the Hungarian authoritites. You must first have a technical user created in the [Online Szamla](https://onlineszamla.nav.gov.hu/home). + +```go +package main + +import ( + "os" + + "github.com/invopop/gobl" + nav "github.com/invopop/gobl.hu-nav" +) + +func main() { -### API Connection -- Nav supports 100 invoice creation/modification in the same request. For the moment, we only support 1 invoice at each request. If this is changed, it should also be changed in status.go, as now status only answers with the errors/warning of the first invoice. + // Software is the information regarding the system used to report the invoices + software := NewSoftware( + tax.Identity{Country: l10n.ES.Tax(), Code: cbc.Code("B12345678")}, + "Invopop", + "ONLINE_SERVICE", + "1.0.0", + "TestDev", + "test@dev.com", + ) -## Invoice issuing order + // User is all the data obtained from the technical user that it is needed to report the invoices + user := NewUser( + "username", + "password", + "signature_key", + "exchange_key", + "taxID" + ) + + // Create a new client with the user and software data and choose if you want to issue the invoices in the testing or production environment + navClient := NewNav(user, software, InTesting()) + + //We load the invoice + inv, err := os.ReadFile("test/data/out/output.xml") + if err != nil { + panic(err) + } + + // Report the invoice + transactionId, err := navClient.ReportInvoice(invoice) + if err != nil { + panic(err) + } + + // Once the invoice is reported, you can check the status + // If you check the status too early you would get a status of PROCESSING, which means that you should try again later to query the status + resultsList, err := navClient.GetTransactionStatus(transactionId) + + //The output contains the status and a list of technical and business validation messages. To visualize the output, you can create a XML output: + out, err := nav.BytesIndent(resultsList) + if err != nil { + panic(err) + } + + // TODO: do something with the output +} +``` + +### Command Line +#### Conversion + +The GOBL NAV package tool also includes a command line helper. You can install manually in your Go environment with: + +```bash +go install ./cmd/gobl.nav +``` + +Usage is very straightforward: + +```bash +gobl.nav convert ./test/data/invoice.json +``` + + +## Limitations/Things to do + +### Doc Conversion +- Batch invoicing not supported +- Modification of invoices not supported +- Support fiscal representatives +- Aggregate invoices not supported +- Product refund charges not supported (Field Product Fee Summary in the Invoice) +- Nav supports 100 invoice creation/modification in the same request. For the moment, we only support 1 invoice at each request. -1. Generate the doc with the format required by the NAV. -2. Obtain an exchange token with 5 mins validity. -3. Issue the invoice (An Ok message when issuing the invoice doesn't mean that it is correctly issued) -4. Check your transaction status (This would give us information about the status of the invoice issuing) +## Tags, Tax and Extensions -If you do step number 4 just after 3, you would get a status of processing, we should retry the operation until we get a status of DONE or ABORT. diff --git a/cmd/gobl.nav/convert.go b/cmd/gobl.nav/convert.go index f7975aa..722b336 100644 --- a/cmd/gobl.nav/convert.go +++ b/cmd/gobl.nav/convert.go @@ -8,7 +8,6 @@ import ( "github.com/invopop/gobl" nav "github.com/invopop/gobl.hu-nav" - "github.com/invopop/gobl/bill" "github.com/spf13/cobra" ) @@ -53,17 +52,12 @@ func (c *convertOpts) runE(cmd *cobra.Command, args []string) error { return fmt.Errorf("unmarshaling gobl envelope: %w", err) } - inv, ok := env.Extract().(*bill.Invoice) - if !ok { - return fmt.Errorf("invalid type %T", env.Document) - } - - doc, err := nav.NewDocument(inv) + doc, err := nav.NewDocument(env) if err != nil { panic(err) } - data, err := doc.BytesIndent() + data, err := nav.BytesIndent(doc) if err != nil { return fmt.Errorf("generating nav xml: %w", err) } diff --git a/internal/doc/address.go b/internal/doc/address.go index 9601b92..431d3ca 100644 --- a/internal/doc/address.go +++ b/internal/doc/address.go @@ -6,6 +6,7 @@ import ( "github.com/invopop/gobl/org" ) +// Address is the format of address that includes either a simple or a detailed address type Address struct { SimpleAddress *SimpleAddress `xml:"base:simpleAddress,omitempty"` DetailedAddress *DetailedAddress `xml:"base:detailedAddress,omitempty"` @@ -27,9 +28,6 @@ type DetailedAddress struct { LotNumber string `xml:"base:lotNumber,omitempty"` } -// GOBL does not support dividing the address into public place category and street name -// For the moment we can use SimpleAddress - // SimpleAddressType represents a simple address type SimpleAddress struct { CountryCode string `xml:"countryCode"` @@ -39,21 +37,23 @@ type SimpleAddress struct { AdditionalAddressDetail string `xml:"base:additionalAddressDetail"` } -func NewAddress(address *org.Address) *Address { - return &Address{ - DetailedAddress: NewDetailedAddress(address), +func newAddress(address *org.Address) *Address { + if address.StreetType != "" { + return &Address{ + DetailedAddress: newDetailedAddress(address), + } } - /*return &Address{ + return &Address{ SimpleAddress: &SimpleAddress{ CountryCode: address.Country.String(), PostalCode: address.Code, City: address.Locality, AdditionalAddressDetail: formatAddress(address), }, - }*/ + } } -func NewDetailedAddress(address *org.Address) *DetailedAddress { +func newDetailedAddress(address *org.Address) *DetailedAddress { return &DetailedAddress{ CountryCode: address.Country.String(), Region: address.Region, diff --git a/internal/doc/address_test.go b/internal/doc/address_test.go index 7da5623..5e9bc87 100644 --- a/internal/doc/address_test.go +++ b/internal/doc/address_test.go @@ -64,7 +64,7 @@ func TestNewAddress(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := NewAddress(tt.input) + result := newAddress(tt.input) assert.Equal(t, tt.expectedOutput, result) }) } diff --git a/internal/doc/customer.go b/internal/doc/customer.go index 87a72f8..deb0dfe 100644 --- a/internal/doc/customer.go +++ b/internal/doc/customer.go @@ -6,20 +6,25 @@ import ( "github.com/invopop/gobl/tax" ) +// CustomerInfo contains the customer data. type CustomerInfo struct { - CustomerVatStatus string `xml:"customerVatStatus"` + CustomerVatStatus string `xml:"customerVatStatus"` // PRIVATE_PERSON, DOMESTIC, OTHER CustomerVatData *VatData `xml:"customerVatData,omitempty"` CustomerName string `xml:"customerName,omitempty"` CustomerAddress *Address `xml:"customerAddress,omitempty"` // CustomerBankAccount string `xml:"customerBankAccountNumber,omitempty"` } +// VatData contains the VAT subjectivity data of the customer. type VatData struct { CustomerTaxNumber *CustomerTaxNumber `xml:"customerTaxNumber,omitempty"` CommunityVATNumber string `xml:"communityVATNumber,omitempty"` ThirdStateTaxId string `xml:"thirdStateTaxId,omitempty"` } +// CustomerTaxNumber contains the domestic tax number or +// the group identification number, under which the purchase of goods +// or services is done type CustomerTaxNumber struct { TaxPayerID string `xml:"base:taxpayerId"` VatCode string `xml:"base:vatCode,omitempty"` @@ -27,14 +32,14 @@ type CustomerTaxNumber struct { GroupMemberTaxNumber *TaxNumber `xml:"groupMemberTaxNumber,omitempty"` } -func NewCustomerInfo(customer *org.Party) (*CustomerInfo, error) { +func newCustomerInfo(customer *org.Party) (*CustomerInfo, error) { taxID := customer.TaxID if taxID == nil { return &CustomerInfo{ CustomerVatStatus: "OTHER", CustomerName: customer.Name, - CustomerAddress: NewAddress(customer.Addresses[0]), + CustomerAddress: newAddress(customer.Addresses[0]), }, nil } status := "OTHER" @@ -44,7 +49,7 @@ func NewCustomerInfo(customer *org.Party) (*CustomerInfo, error) { return &CustomerInfo{ CustomerVatStatus: "PRIVATE_PERSON", CustomerName: customer.Name, - CustomerAddress: NewAddress(customer.Addresses[0]), + CustomerAddress: newAddress(customer.Addresses[0]), }, nil } status = "DOMESTIC" @@ -60,7 +65,7 @@ func NewCustomerInfo(customer *org.Party) (*CustomerInfo, error) { CustomerVatStatus: status, CustomerVatData: vatData, CustomerName: customer.Name, - CustomerAddress: NewAddress(customer.Addresses[0]), + CustomerAddress: newAddress(customer.Addresses[0]), }, nil } @@ -83,7 +88,7 @@ func newOtherVatData(taxID *tax.Identity) *VatData { } func newDomesticVatData(customer *org.Party) (*VatData, error) { - taxNumber, groupNumber, err := NewTaxNumber(customer) + taxNumber, groupNumber, err := newTaxNumber(customer) if err != nil { return nil, err } diff --git a/internal/doc/customer_test.go b/internal/doc/customer_test.go index de40486..d68a368 100644 --- a/internal/doc/customer_test.go +++ b/internal/doc/customer_test.go @@ -146,7 +146,7 @@ func TestNewCustomerInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - customerInfo, err := NewCustomerInfo(tt.customer) + customerInfo, err := newCustomerInfo(tt.customer) if err != tt.expectedErr { t.Errorf("expected error %v, got %v", tt.expectedErr, err) diff --git a/internal/doc/detail.go b/internal/doc/detail.go index e62d47c..b9e353e 100644 --- a/internal/doc/detail.go +++ b/internal/doc/detail.go @@ -3,6 +3,7 @@ package doc import ( "github.com/invopop/gobl/bill" "github.com/invopop/gobl/currency" + "github.com/invopop/gobl/num" "github.com/invopop/gobl/tax" ) @@ -15,8 +16,8 @@ type InvoiceDetail struct { //InvoiceAccountingDeliveryDate string `xml:"invoiceAccountingDeliveryDate,omitempty"` //PeriodicalSettlement bool `xml:"periodicalSettlement,omitempty"` //SmallBusinessIndicator bool `xml:"smallBusinessIndicator,omitempty"` - CurrencyCode string `xml:"currencyCode"` - ExchangeRate float64 `xml:"exchangeRate"` + CurrencyCode string `xml:"currencyCode"` + ExchangeRate string `xml:"exchangeRate"` //UtilitySettlementIndicator bool `xml:"utilitySettlementIndicator,omitempty"` //SelfBillingIndicator bool `xml:"selfBillingIndicator,omitempty"` PaymentMethod string `xml:"paymentMethod,omitempty"` @@ -26,8 +27,8 @@ type InvoiceDetail struct { //Some more optional data } -// NewInvoiceDetail creates a new InvoiceDetail from an invoice -func NewInvoiceDetail(inv *bill.Invoice) (*InvoiceDetail, error) { +// newInvoiceDetail creates a new InvoiceDetail from an invoice +func newInvoiceDetail(inv *bill.Invoice) (*InvoiceDetail, error) { category := "NORMAL" if inv.Tax.ContainsTag(tax.TagSimplified) { category = "SIMPLIFIED" @@ -66,7 +67,7 @@ func NewInvoiceDetail(inv *bill.Invoice) (*InvoiceDetail, error) { paymentMethod = "TRANSFER" case "card": paymentMethod = "CARD" - // There is one case that is VOUCHER + // There is one case that is VOUCHER, but I didn't find anything in GOBL similar default: paymentMethod = "OTHER" } @@ -84,23 +85,23 @@ func NewInvoiceDetail(inv *bill.Invoice) (*InvoiceDetail, error) { InvoiceDeliveryPeriodStart: periodStart, InvoiceDeliveryPeriodEnd: periodEnd, CurrencyCode: inv.Currency.String(), - ExchangeRate: rate, + ExchangeRate: rate.String(), PaymentMethod: paymentMethod, PaymentDate: dueDate, InvoiceAppearance: "EDI", }, nil } -func getInvoiceRate(inv *bill.Invoice) (float64, error) { +func getInvoiceRate(inv *bill.Invoice) (num.Amount, error) { if inv.Currency == currency.HUF { - return 1.0, nil + return num.MakeAmount(1, 0), nil } for _, ex := range inv.ExchangeRates { if ex.To == currency.HUF { - return ex.Amount.Rescale(6).Float64(), nil + return ex.Amount.Rescale(6), nil } } - return -1.0, ErrNoExchangeRate + return num.AmountZero, ErrNoExchangeRate } diff --git a/internal/doc/detail_test.go b/internal/doc/detail_test.go index 07d8644..35e8b6b 100644 --- a/internal/doc/detail_test.go +++ b/internal/doc/detail_test.go @@ -36,7 +36,7 @@ func TestNewInvoiceDetail_NormalInvoice(t *testing.T) { }, } - detail, err := NewInvoiceDetail(invoice) + detail, err := newInvoiceDetail(invoice) assert.NoError(t, err) assert.Equal(t, "NORMAL", detail.InvoiceCategory) assert.Equal(t, "2023-08-10", detail.InvoiceDeliveryDate) @@ -65,7 +65,7 @@ func TestNewInvoiceDetail_SimplifiedInvoice(t *testing.T) { }, } - detail, err := NewInvoiceDetail(invoice) + detail, err := newInvoiceDetail(invoice) assert.NoError(t, err) assert.Equal(t, "SIMPLIFIED", detail.InvoiceCategory) } @@ -83,7 +83,7 @@ func TestNewInvoiceDetail_NoExchangeRate(t *testing.T) { IssueDate: *cal.NewDate(2023, 7, 15), } - _, err := NewInvoiceDetail(invoice) + _, err := newInvoiceDetail(invoice) assert.Error(t, err) assert.Equal(t, ErrNoExchangeRate, err) } diff --git a/internal/doc/doc.go b/internal/doc/doc.go index d94b4b5..ec8fcf9 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -1,18 +1,14 @@ package doc import ( - "bytes" "encoding/xml" "errors" "fmt" + "github.com/invopop/gobl" "github.com/invopop/gobl/bill" ) -/* -*/ - const ( XMNLSDATA = "http://schemas.nav.gov.hu/OSA/3.0/data" XMNLSCOMMON = "http://schemas.nav.gov.hu/NTCA/1.0/common" @@ -42,6 +38,7 @@ func newValidationError(text string) error { return &ValidationError{errors.New(text)} } +// Document is the root element of the XML document. type Document struct { XMLName xml.Name `xml:"InvoiceData"` XMLNS string `xml:"xmlns,attr"` @@ -51,12 +48,16 @@ type Document struct { XMLNSBase string `xml:"xmlns:base,attr"` InvoiceNumber string `xml:"invoiceNumber"` InvoiceIssueDate string `xml:"invoiceIssueDate"` - CompletenessIndicator bool `xml:"completenessIndicator"` // Indicates whether the data report is the invoice itself + CompletenessIndicator bool `xml:"completenessIndicator"` // Indicates whether the data exchange is identical with the invoice (the invoice does not contain any more data) InvoiceMain *InvoiceMain `xml:"invoiceMain"` } -// Convert it to XML before returning -func NewDocument(inv *bill.Invoice) (*Document, error) { +// NewDocument creates a new Document from an envelope. +func NewDocument(env *gobl.Envelope) (*Document, error) { + inv, ok := env.Extract().(*bill.Invoice) + if !ok { + return nil, fmt.Errorf("invalid type %T", env.Document) + } d := new(Document) d.XMLNS = XMNLSDATA d.XMLNSXsi = XMNLXSI @@ -66,39 +67,10 @@ func NewDocument(inv *bill.Invoice) (*Document, error) { d.InvoiceNumber = inv.Code d.InvoiceIssueDate = inv.IssueDate.String() d.CompletenessIndicator = false - main, err := NewInvoiceMain(inv) + main, err := newInvoiceMain(inv) if err != nil { return nil, err } d.InvoiceMain = main return d, nil } - -// BytesIndent returns the indented XML document bytes -func (doc *Document) BytesIndent() ([]byte, error) { - return toBytesIndent(doc) -} - -func toBytesIndent(doc any) ([]byte, error) { - buf, err := buffer(doc, xml.Header, true) - if err != nil { - return nil, err - } - - return buf.Bytes(), nil -} - -func buffer(doc any, base string, indent bool) (*bytes.Buffer, error) { - buf := bytes.NewBufferString(base) - - enc := xml.NewEncoder(buf) - if indent { - enc.Indent("", " ") - } - - if err := enc.Encode(doc); err != nil { - return nil, fmt.Errorf("encoding document: %w", err) - } - - return buf, nil -} diff --git a/internal/doc/doc_test.go b/internal/doc/doc_test.go index 7892076..c7dfe9e 100644 --- a/internal/doc/doc_test.go +++ b/internal/doc/doc_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/invopop/gobl" - "github.com/invopop/gobl/bill" "github.com/lestrrat-go/libxml2" "github.com/lestrrat-go/libxml2/xsd" "github.com/stretchr/testify/assert" @@ -25,12 +24,8 @@ func TestNewDocument(t *testing.T) { err = json.Unmarshal(data, env) require.NoError(t, err, "Failed to unmarshal test invoice JSON") - // Extract the invoice from the envelope - inv, ok := env.Extract().(*bill.Invoice) - require.True(t, ok, "Failed to extract invoice from envelope") - // Call the NewDocument function - doc, err := NewDocument(inv) + doc, err := NewDocument(env) require.NoError(t, err, "Failed to create new document") xmlData, err := xml.MarshalIndent(doc, "", " ") @@ -82,8 +77,6 @@ func TestNewDocument(t *testing.T) { assert.Equal(t, XSIDataSchema, doc.XSISchema, "Unexpected XSISchema value") assert.Equal(t, XMNLSCOMMON, doc.XMLNSCommon, "Unexpected XMLNSCommon value") assert.Equal(t, XMNLBASE, doc.XMLNSBase, "Unexpected XMLNSBase value") - assert.Equal(t, inv.Code, doc.InvoiceNumber, "Unexpected InvoiceNumber value") - assert.Equal(t, inv.IssueDate.String(), doc.InvoiceIssueDate, "Unexpected InvoiceIssueDate value") assert.False(t, doc.CompletenessIndicator, "Unexpected CompletenessIndicator value") // Assert that InvoiceMain is not nil diff --git a/internal/doc/head.go b/internal/doc/head.go index 3151d3d..7131e4e 100644 --- a/internal/doc/head.go +++ b/internal/doc/head.go @@ -11,18 +11,18 @@ type InvoiceHead struct { InvoiceDetail *InvoiceDetail `xml:"invoiceDetail"` } -func NewInvoiceHead(inv *bill.Invoice) (*InvoiceHead, error) { - supplierInfo, err := NewSupplierInfo(inv.Supplier) +func newInvoiceHead(inv *bill.Invoice) (*InvoiceHead, error) { + supplierInfo, err := newSupplierInfo(inv.Supplier) if err != nil { return nil, err } - customerInfo, err := NewCustomerInfo(inv.Customer) + customerInfo, err := newCustomerInfo(inv.Customer) if err != nil { return nil, err } - detail, err := NewInvoiceDetail(inv) + detail, err := newInvoiceDetail(inv) if err != nil { return nil, err } diff --git a/internal/doc/invoice.go b/internal/doc/invoice.go index bb5e6c3..ea30824 100644 --- a/internal/doc/invoice.go +++ b/internal/doc/invoice.go @@ -2,13 +2,15 @@ package doc import "github.com/invopop/gobl/bill" -// InvoiceMain can have 2 values: Invoice and batchInvoice -// For the moment, we are only going to focus on invoice +// InvoiceMain contains the invoice data. +// It can be used for both invoice creation and modification. +// It can have 2 values: Invoice and batchInvoice type InvoiceMain struct { Invoice *Invoice `xml:"invoice"` //BatchInvoice *BatchInvoice `xml:"batchInvoice"` // Used only for batch modifications } +// Invoice is the main invoice data structure. type Invoice struct { //InvoiceReference InvoiceReference `xml:"invoiceReference,omitempty"` // Used for invoice modification (reference other invoice) InvoiceHead *InvoiceHead `xml:"invoiceHead"` @@ -18,18 +20,18 @@ type Invoice struct { InvoiceSummary *InvoiceSummary `xml:"invoiceSummary"` } -func NewInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { - invoiceHead, err := NewInvoiceHead(inv) +func newInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { + invoiceHead, err := newInvoiceHead(inv) if err != nil { return nil, err } - invoiceLines, err := NewInvoiceLines(inv) + invoiceLines, err := newInvoiceLines(inv) if err != nil { return nil, err } - invoiceSummary, err := NewInvoiceSummary(inv) + invoiceSummary, err := newInvoiceSummary(inv) if err != nil { return nil, err } diff --git a/internal/doc/lines.go b/internal/doc/lines.go index d264aea..3677ac0 100644 --- a/internal/doc/lines.go +++ b/internal/doc/lines.go @@ -8,11 +8,13 @@ import ( "github.com/invopop/gobl/tax" ) +// InvoiceLines contains the product/service data appearing on the invoice. type InvoiceLines struct { MergedItemIndicator bool `xml:"mergedItemIndicator"` // Indicates if the data report contains aggregated item data Lines []Line `xml:"line"` } +// Line contains the data of a single product or service on the invoice. type Line struct { LineNumber int `xml:"lineNumber"` //LineModificationReference LineModificationReference `xml:"lineModificationReference,omitempty"` @@ -44,10 +46,12 @@ type Line struct { //AdditionalLineData AdditionalData `xml:"additionalLineData,omitempty"` // Additional data } +// ProductCodes contains the product codes of a product or service. type ProductCodes struct { ProductCode []*ProductCode `xml:"productCode"` } +// ProductCode contains the product code of a product or service. // One of code value or codeownvalue must be present type ProductCode struct { ProductCodeCategory string `xml:"productCodeCategory"` // Product code value for non-own product codes @@ -55,12 +59,14 @@ type ProductCode struct { ProductCodeOwnValue string `xml:"productCodeOwnValue,omitempty"` // Own product code value } +// LineDiscountData contains the data of a discount on a line. type LineDiscountData struct { DiscountDescription string `xml:"discountDescription"` DiscountValue string `xml:"discountValue"` DiscountRate string `xml:"discountRate"` } +// LineAmountsNormal contains the net, VAT and gross amounts of a line in a normal invoice. type LineAmountsNormal struct { LineNetAmountData *LineNetAmountData `xml:"lineNetAmountData"` LineVatRate *VatRate `xml:"lineVatRate"` @@ -68,11 +74,13 @@ type LineAmountsNormal struct { LineGrossAmountData *LineGrossAmountData `xml:"lineGrossAmountData,omitempty"` } +// LineNetAmountData contains the net amount of a line. type LineNetAmountData struct { LineNetAmount string `xml:"lineNetAmount"` LineNetAmountHUF string `xml:"lineNetAmountHUF"` } +// VatRate contains the VAT rate and amount of a line. type LineVatData struct { LineVatAmount string `xml:"lineVatAmount"` LineVatAmountHUF string `xml:"lineVatAmountHUF"` @@ -84,6 +92,7 @@ type LineGrossAmountData struct { LineGrossAmountHUF string `xml:"lineGrossAmountHUF"` } +// LineAmountsSimplified contains the amounts of a line in a simplified invoice. type LineAmountsSimplified struct { LineVatRate *VatRate `xml:"lineVatRate"` LineGrossAmountSimplified string `xml:"lineGrossAmountSimplified"` //This amount is the net amount of the normal line @@ -100,8 +109,7 @@ var validUnits = map[org.Unit]string{ org.UnitLitre: "LITRE", org.UnitKilometre: "KILOMETER", org.UnitCubicMetre: "CUBIC_METER", org.UnitMetre: "METER", org.UnitCarton: "CARTON", org.UnitPackage: "PACK"} -func NewInvoiceLines(inv *bill.Invoice) (*InvoiceLines, error) { - +func newInvoiceLines(inv *bill.Invoice) (*InvoiceLines, error) { invoiceLines := &InvoiceLines{} taxinfo := newTaxInfo(inv) rate, err := getInvoiceRate(inv) @@ -109,7 +117,7 @@ func NewInvoiceLines(inv *bill.Invoice) (*InvoiceLines, error) { return nil, err } for _, line := range inv.Lines { - invoiceLine, err := NewLine(line, taxinfo, rate) + invoiceLine, err := newLine(line, taxinfo, rate) if err != nil { return nil, err } @@ -120,7 +128,7 @@ func NewInvoiceLines(inv *bill.Invoice) (*InvoiceLines, error) { return invoiceLines, nil } -func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { +func newLine(line *bill.Line, info *taxInfo, rate num.Amount) (*Line, error) { lineNav := &Line{ LineNumber: line.Index, LineExpressionIndicator: false, @@ -131,9 +139,10 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { } if line.Item.Identities != nil { - lineNav.ProductCodes = NewProductCodes(line.Item.Identities) + lineNav.ProductCodes = newProductCodes(line.Item.Identities) } + // If a unit is included we check if it is a valid unit if line.Item.Unit != "" { for unit, value := range validUnits { if line.Item.Unit == unit { @@ -179,7 +188,7 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { LineGrossAmountSimplifiedHUF: amountToHUF(line.Total, rate).String(), } } else { - vatRate, err := NewVatRate(vatCombo, info) + vatRate, err := newVatRate(vatCombo, info) if err != nil { return nil, err } @@ -195,19 +204,19 @@ func NewLine(line *bill.Line, info *taxInfo, rate float64) (*Line, error) { return lineNav, nil } -func NewProductCodes(identities []*org.Identity) *ProductCodes { +func newProductCodes(identities []*org.Identity) *ProductCodes { if len(identities) == 0 { return nil } productCodes := &ProductCodes{} for _, identity := range identities { - productCode := NewProductCode(identity) + productCode := newProductCode(identity) productCodes.ProductCode = append(productCodes.ProductCode, productCode) } return productCodes } -func NewProductCode(identity *org.Identity) *ProductCode { +func newProductCode(identity *org.Identity) *ProductCode { if identity.Type == "OWN" { return &ProductCode{ ProductCodeCategory: "OWN", @@ -228,7 +237,7 @@ func NewProductCode(identity *org.Identity) *ProductCode { } } -func amountToHUF(amount num.Amount, ex float64) num.Amount { - result := amount.Multiply(num.AmountFromFloat64(ex, 5)) +func amountToHUF(amount num.Amount, ex num.Amount) num.Amount { + result := amount.Multiply(ex) return result.Rescale(2) } diff --git a/internal/doc/lines_test.go b/internal/doc/lines_test.go index 384a5c7..66d1309 100644 --- a/internal/doc/lines_test.go +++ b/internal/doc/lines_test.go @@ -45,7 +45,7 @@ func TestNewInvoiceLines(t *testing.T) { } // Execute the function under test - invoiceLines, err := NewInvoiceLines(invoice) + invoiceLines, err := newInvoiceLines(invoice) // Assertions assert.NoError(t, err) @@ -99,10 +99,10 @@ func TestNewLine_SimplifiedInvoice(t *testing.T) { } info := &taxInfo{simplifiedInvoice: true} - rate := 1.0 + rate := num.MakeAmount(1, 0) // Execute the function under test - line, err := NewLine(invoice.Lines[0], info, rate) + line, err := newLine(invoice.Lines[0], info, rate) // Assertions assert.NoError(t, err) diff --git a/internal/doc/summary.go b/internal/doc/summary.go index 195ed11..1b4a0bb 100644 --- a/internal/doc/summary.go +++ b/internal/doc/summary.go @@ -38,8 +38,8 @@ type VatRateVatData struct { VatRateVatAmountHUF string `xml:"vatRateVatAmountHUF"` } -func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex float64) (*SummaryByVatRate, error) { - vatRate, err := NewVatRate(rate, info) +func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex num.Amount) (*SummaryByVatRate, error) { + vatRate, err := newVatRate(rate, info) if err != nil { return nil, err } @@ -56,7 +56,7 @@ func newSummaryByVatRate(rate *tax.RateTotal, info *taxInfo, ex float64) (*Summa }, nil } -func NewInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { +func newInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { vat := inv.Totals.Taxes.Category(tax.CategoryVAT) totalVat := num.MakeAmount(0, 5) summaryVat := []*SummaryByVatRate{} diff --git a/internal/doc/supplier.go b/internal/doc/supplier.go index 34e209a..bb93a20 100644 --- a/internal/doc/supplier.go +++ b/internal/doc/supplier.go @@ -4,19 +4,20 @@ import ( "github.com/invopop/gobl/org" ) +// SupplierInfo contains data related to the issuer of the invoice (supplier). type SupplierInfo struct { SupplierTaxNumber *TaxNumber `xml:"supplierTaxNumber"` GroupMemberTaxNumber *TaxNumber `xml:"groupMemberTaxNumber,omitempty"` - //CommunityVATNumber string `xml:"communityVATNumber,omitempty"` // This is just the same as Supplier Number is HU + //CommunityVATNumber string `xml:"communityVATNumber,omitempty"` // This is just the same as Supplier Number with HU prefix SupplierName string `xml:"supplierName"` SupplierAddress *Address `xml:"supplierAddress"` //SupplierBankAccount string `xml:"supplierBankAccount,omitempty"` // Not generally used //IndividualExemption bool `xml:"individualExemption,omitempty"` // Value is "true" if the seller has individual VAT exempt status - //ExciseLicenceNum string `xml:"exciseLicenceNum,omitempty"` + //ExciseLicenceNum string `xml:"exciseLicenceNum,omitempty"` // Number of supplier’s tax warehouse license or excise license (Act LXVIII of 2016) } -func NewSupplierInfo(supplier *org.Party) (*SupplierInfo, error) { - taxNumber, groupNumber, err := NewTaxNumber(supplier) +func newSupplierInfo(supplier *org.Party) (*SupplierInfo, error) { + taxNumber, groupNumber, err := newTaxNumber(supplier) if err != nil { return nil, err } @@ -25,13 +26,13 @@ func NewSupplierInfo(supplier *org.Party) (*SupplierInfo, error) { SupplierTaxNumber: taxNumber, GroupMemberTaxNumber: groupNumber, SupplierName: supplier.Name, - SupplierAddress: NewAddress(supplier.Addresses[0]), + SupplierAddress: newAddress(supplier.Addresses[0]), }, nil } return &SupplierInfo{ SupplierTaxNumber: taxNumber, SupplierName: supplier.Name, - SupplierAddress: NewAddress(supplier.Addresses[0]), + SupplierAddress: newAddress(supplier.Addresses[0]), }, nil } diff --git a/internal/doc/supplier_test.go b/internal/doc/supplier_test.go index fb9e0d3..c652b64 100644 --- a/internal/doc/supplier_test.go +++ b/internal/doc/supplier_test.go @@ -30,7 +30,7 @@ func TestNewSupplierInfo(t *testing.T) { taxNumber := &TaxNumber{TaxPayerID: "98109858"} groupNumber := (*TaxNumber)(nil) - supplierInfo, err := NewSupplierInfo(supplier) + supplierInfo, err := newSupplierInfo(supplier) require.NoError(t, err) assert.NotNil(t, supplierInfo) assert.Equal(t, taxNumber, supplierInfo.SupplierTaxNumber) @@ -61,7 +61,7 @@ func TestNewSupplierInfo(t *testing.T) { taxNumber = &TaxNumber{TaxPayerID: "88212131", VatCode: "5", CountyCode: "03"} groupNumber = &TaxNumber{TaxPayerID: "21114445", VatCode: "4", CountyCode: "23"} - supplierInfo, err = NewSupplierInfo(supplier) + supplierInfo, err = newSupplierInfo(supplier) require.NoError(t, err) assert.NotNil(t, supplierInfo) diff --git a/internal/doc/taxnumber.go b/internal/doc/taxnumber.go index 9c651ce..7767356 100644 --- a/internal/doc/taxnumber.go +++ b/internal/doc/taxnumber.go @@ -5,38 +5,38 @@ import ( "github.com/invopop/gobl/org" ) +// TaxNumber contains the tax number or group identification number of a party. type TaxNumber struct { TaxPayerID string `xml:"base:taxpayerId"` VatCode string `xml:"base:vatCode,omitempty"` CountyCode string `xml:"base:countyCode,omitempty"` } -// Have to look at the vatcodes for the regime - -// NewTaxNumber creates a new TaxNumber from a taxid -func NewTaxNumber(party *org.Party) (*TaxNumber, *TaxNumber, error) { +func newTaxNumber(party *org.Party) (*TaxNumber, *TaxNumber, error) { taxID := party.TaxID if taxID.Country == l10n.HU.Tax() { - // If the vat code is 5, then the group member code should be 4 if len(taxID.Code) == 11 { + // If the 9th character of the tax number is 5, then it is a group member tax number if taxID.Code.String()[8:9] == "5" { groupMemberCode := party.Identities[0].Code.String() - return NewHungarianTaxNumber(taxID.Code.String()), - NewHungarianTaxNumber(groupMemberCode), nil + return newHungarianTaxNumber(taxID.Code.String()), + newHungarianTaxNumber(groupMemberCode), nil } - return NewHungarianTaxNumber(taxID.Code.String()), nil, nil + return newHungarianTaxNumber(taxID.Code.String()), nil, nil } + // If the tax number is not 11 characters long, then it is 8 characters long return &TaxNumber{ TaxPayerID: taxID.Code.String(), }, nil, nil } + // If it is not a Hungarian tax number, then return the tax number with the country code return &TaxNumber{ TaxPayerID: taxID.String(), }, nil, nil } -func NewHungarianTaxNumber(code string) *TaxNumber { +func newHungarianTaxNumber(code string) *TaxNumber { return &TaxNumber{ TaxPayerID: code[:8], VatCode: code[8:9], diff --git a/internal/doc/taxnumber_test.go b/internal/doc/taxnumber_test.go index 2fe77bd..6f48d00 100644 --- a/internal/doc/taxnumber_test.go +++ b/internal/doc/taxnumber_test.go @@ -88,7 +88,7 @@ func TestNewTaxNumber(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mainTaxNum, groupTaxNum, err := NewTaxNumber(tt.party) + mainTaxNum, groupTaxNum, err := newTaxNumber(tt.party) if err != tt.expectedErr { t.Errorf("expected error %v, got %v", tt.expectedErr, err) diff --git a/internal/doc/vatrate.go b/internal/doc/vatrate.go index ddf51f2..7c3ca73 100644 --- a/internal/doc/vatrate.go +++ b/internal/doc/vatrate.go @@ -6,7 +6,8 @@ import ( "github.com/invopop/gobl/tax" ) -// Vat Rate may contain exactly one of the 8 possible fields +// VatRate contains the VAT rate information. +// VatRate may contain exactly one of the 8 possible fields type VatRate struct { VatPercentage string `xml:"vatPercentage,omitempty"` VatContent string `xml:"vatContent,omitempty"` //VatContent is only for simplified invoices @@ -18,11 +19,13 @@ type VatRate struct { NoVatCharge bool `xml:"noVatCharge,omitempty"` } +// DetailedReason contains the case and reason of a VAT exemption or out of scope type DetailedReason struct { Case string `xml:"case"` Reason string `xml:"reason"` } +// VatAmountMismatch contains the vat rate and case of a vat amount mismatch type VatAmountMismatch struct { VatRate float64 `xml:"vatRate"` Case string `xml:"case"` @@ -37,7 +40,7 @@ type taxInfo struct { antique bool } -func NewVatRate(obj any, info *taxInfo) (*VatRate, error) { +func newVatRate(obj any, info *taxInfo) (*VatRate, error) { switch obj := obj.(type) { case *tax.RateTotal: return newVatRateTotal(obj, info) @@ -47,7 +50,6 @@ func NewVatRate(obj any, info *taxInfo) (*VatRate, error) { return nil, nil } -// NewVatRate creates a new VatRate from a taxid func newVatRateTotal(rate *tax.RateTotal, info *taxInfo) (*VatRate, error) { // First if it is not exent or simplified invoice we can return the percentage if rate.Percent != nil { @@ -61,11 +63,11 @@ func newVatRateTotal(rate *tax.RateTotal, info *taxInfo) (*VatRate, error) { // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode for k, v := range rate.Ext { - if k == "hu-exemption-code" { //hu.ExtKeyExemptionCode { + if k == hu.ExtKeyExemptionCode { return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}}, nil } - if k == "hu-vat-out-of-scope-code" { //hu.ExtKeyVatOutOfScopeCode { + if k == hu.ExtKeyVatOutOfScopeCode { return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}}, nil } } @@ -90,7 +92,7 @@ func newVatRateTotal(rate *tax.RateTotal, info *taxInfo) (*VatRate, error) { return &VatRate{MarginSchemeIndicator: "ANTIQUE"}, nil } - // Missing vat amount mismatch + //TODO: Missing vat amount mismatch // If percent is nil if rate.Percent == nil { @@ -109,11 +111,11 @@ func newVatRateCombo(c *tax.Combo, info *taxInfo) (*VatRate, error) { // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode for k, v := range c.Ext { - if k == "hu-exemption-code" { //hu.ExtKeyExemptionCode { + if k == hu.ExtKeyExemptionCode { return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}}, nil } - if k == "hu-vat-out-of-scope-code" { //hu.ExtKeyVatOutOfScopeCode { + if k == hu.ExtKeyVatOutOfScopeCode { return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}}, nil } } diff --git a/internal/doc/vatrate_test.go b/internal/doc/vatrate_test.go index 21a511d..63e93dc 100644 --- a/internal/doc/vatrate_test.go +++ b/internal/doc/vatrate_test.go @@ -99,7 +99,7 @@ func TestNewVatRate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - vatRate, err := NewVatRate(tt.input, tt.info) + vatRate, err := newVatRate(tt.input, tt.info) if tt.expectErr { assert.Error(t, err) } else { diff --git a/internal/gateways/invoice.go b/internal/gateways/invoice.go index fc566d2..b3db07c 100644 --- a/internal/gateways/invoice.go +++ b/internal/gateways/invoice.go @@ -1,6 +1,7 @@ package gateways import ( + "encoding/base64" "encoding/xml" "fmt" "time" @@ -46,8 +47,11 @@ type ManageInvoiceResponse struct { TransactionId string `xml:"transactionId"` } -func (g *Client) ReportInvoice(invoice string) (string, error) { - requestData := g.newManageInvoiceRequest(invoice) +func (g *Client) ReportInvoice(invoice []byte) (string, error) { + // We first fetch the exchange token + g.GetToken() + encodedInvoice := base64.StdEncoding.EncodeToString(invoice) + requestData := g.newManageInvoiceRequest(encodedInvoice) return g.postManageInvoiceRequest(requestData) } diff --git a/nav.go b/nav.go index 84292aa..749c56e 100644 --- a/nav.go +++ b/nav.go @@ -1,9 +1,13 @@ package nav import ( + "bytes" + "encoding/xml" + "fmt" + + "github.com/invopop/gobl" "github.com/invopop/gobl.hu-nav/internal/doc" "github.com/invopop/gobl.hu-nav/internal/gateways" - "github.com/invopop/gobl/bill" "github.com/invopop/gobl/tax" ) @@ -45,7 +49,7 @@ func (n *Nav) FetchToken() error { return n.gw.GetToken() } -func (n *Nav) ReportInvoice(invoice string) (string, error) { +func (n *Nav) ReportInvoice(invoice []byte) (string, error) { return n.gw.ReportInvoice(invoice) } @@ -63,6 +67,31 @@ func NewUser(login string, password string, signKey string, exchangeKey string, return gateways.NewUser(login, password, signKey, exchangeKey, taxNumber) } -func NewDocument(inv *bill.Invoice) (*doc.Document, error) { - return doc.NewDocument(inv) +func NewDocument(env *gobl.Envelope) (*doc.Document, error) { + return doc.NewDocument(env) +} + +// BytesIndent returns the indented XML document bytes +func BytesIndent(doc any) ([]byte, error) { + buf, err := buffer(doc, xml.Header, true) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func buffer(doc any, base string, indent bool) (*bytes.Buffer, error) { + buf := bytes.NewBufferString(base) + + enc := xml.NewEncoder(buf) + if indent { + enc.Indent("", " ") + } + + if err := enc.Encode(doc); err != nil { + return nil, fmt.Errorf("encoding document: %w", err) + } + + return buf, nil } diff --git a/nav_test.go b/nav_test.go index 6f415d6..84926c4 100644 --- a/nav_test.go +++ b/nav_test.go @@ -1,7 +1,6 @@ package nav import ( - "encoding/base64" "encoding/xml" "fmt" "log" @@ -23,7 +22,7 @@ func TestReportInvoice(t *testing.T) { "ONLINE_SERVICE", "1.0.0", "TestDev", - "pablo.menendez@invopop.com", + "test@dev.com", ) err := godotenv.Load(".env") @@ -41,15 +40,12 @@ func TestReportInvoice(t *testing.T) { navClient := NewNav(user, software, InTesting()) - xmlContent, err := os.ReadFile("test/data/out/output.xml") + invoice, err := os.ReadFile("test/data/out/output.xml") if err != nil { t.Fatalf("Failed to read sample invoice file: %v", err) } - encodedInvoice := base64.StdEncoding.EncodeToString(xmlContent) - navClient.FetchToken() - - transactionId, err := navClient.ReportInvoice(encodedInvoice) + transactionId, err := navClient.ReportInvoice(invoice) fmt.Println("Transaction ID: ", transactionId) diff --git a/invoice_test.json b/test/data/invoice-test.json similarity index 100% rename from invoice_test.json rename to test/data/invoice-test.json diff --git a/test/data/invoice_test.json b/test/data/invoice_test.json deleted file mode 100644 index 2c65a17..0000000 --- a/test/data/invoice_test.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "$schema": "https://gobl.org/draft-0/envelope", - "head": { - "uuid": "9b62fd40-3b38-11ee-be56-0242ac120003", - "dig": { - "alg": "sha256", - "val": "fb3e81ee5d0964fa423dcfb62309a7a5c5150dc62cdd81427a68a2c85e893a66" - }, - "draft": true - }, - "doc": { - "$schema": "https://gobl.org/draft-0/bill/invoice", - "type": "standard", - "series": "SAMPLE", - "code": "001", - "issue_date": "2024-02-13", - "currency": "HUF", - "supplier": { - "name": "Provide One Kft.", - "tax_id": { - "country": "HU", - "code": "12345678" - }, - "people": [ - { - "name": { - "given": "János", - "surname": "Kovács" - } - } - ], - "addresses": [ - { - "num": "16", - "street": "Andrássy út", - "locality": "Budapest", - "code": "1061", - "country": "HU" - } - ], - "emails": [ - { - "addr": "szamlazas@example.com" - } - ], - "telephones": [ - { - "num": "+36100200300" - } - ] - }, - "customer": { - "name": "Minta Fogyasztó", - "tax_id": { - "country": "HU", - "code": "87654321" - }, - "addresses": [ - { - "num": "25", - "street": "Váci utca", - "locality": "Budapest", - "code": "1052", - "country": "HU" - } - ], - "emails": [ - { - "addr": "email@minta.com" - } - ] - }, - "lines": [ - { - "i": 1, - "quantity": "20", - "item": { - "name": "Fejlesztési szolgáltatások", - "price": "30000.00", - "unit": "h" - }, - "sum": "600000.00", - "taxes": [ - { - "cat": "VAT", - "rate": "standard", - "percent": "27%" - } - ], - "total": "600000.00" - } - ], - "ordering": { - "code": "XR-2024-2" - }, - "payment": { - "terms": { - "detail": "lorem ipsum" - } - }, - "totals": { - "sum": "600000.00", - "total": "600000.00", - "taxes": { - "categories": [ - { - "code": "VAT", - "rates": [ - { - "key": "standard", - "base": "600000.00", - "percent": "27%", - "amount": "162000.00" - } - ], - "amount": "162000.00" - } - ], - "sum": "162000.00" - }, - "tax": "162000.00", - "total_with_tax": "762000.00", - "payable": "762000.00" - } - } -} \ No newline at end of file From c8c6253f89372014dfecb2b3acb047dc293eda7c Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Fri, 30 Aug 2024 17:30:01 +0000 Subject: [PATCH 21/27] Testing --- .golangci.toml | 15 ++ README.md | 6 +- internal/doc/address.go | 4 +- internal/doc/address_test.go | 35 ++-- internal/doc/customer.go | 21 ++- internal/doc/customer_test.go | 4 +- internal/doc/detail.go | 4 +- internal/doc/detail_test.go | 2 +- internal/doc/doc.go | 91 +++++++++-- internal/doc/doc_test.go | 149 +++++++++++------- internal/doc/invoice.go | 20 ++- internal/doc/lines.go | 2 +- internal/doc/lines_test.go | 14 +- internal/doc/reference.go | 24 +++ internal/doc/summary.go | 2 +- internal/doc/vatrate.go | 30 ++-- internal/doc/vatrate_test.go | 4 +- internal/gateways/invoice.go | 12 +- internal/gateways/status_test.go | 4 +- internal/gateways/token.go | 5 +- nav.go | 17 +- nav_test.go | 14 +- test/data/b2c.json | 96 +++++++++++ .../{invoice-test.json => credit-note.json} | 12 +- test/data/foreign.json | 112 +++++++++++++ test/data/invoice-standard.json | 100 ++++++++++++ test/data/out/b2c.xml | 89 +++++++++++ test/data/out/credit-note.xml | 96 +++++++++++ {examples => test/data/out}/example.xml | 0 test/data/out/foreign.xml | 97 ++++++++++++ .../out/{output.xml => invoice-standard.xml} | 25 ++- 31 files changed, 935 insertions(+), 171 deletions(-) create mode 100644 .golangci.toml create mode 100644 internal/doc/reference.go create mode 100644 test/data/b2c.json rename test/data/{invoice-test.json => credit-note.json} (94%) create mode 100644 test/data/foreign.json create mode 100644 test/data/invoice-standard.json create mode 100644 test/data/out/b2c.xml create mode 100644 test/data/out/credit-note.xml rename {examples => test/data/out}/example.xml (100%) create mode 100644 test/data/out/foreign.xml rename test/data/out/{output.xml => invoice-standard.xml} (83%) diff --git a/.golangci.toml b/.golangci.toml new file mode 100644 index 0000000..a73d1da --- /dev/null +++ b/.golangci.toml @@ -0,0 +1,15 @@ +[run] +timeout = "120s" + +[output] +format = "colored-line-number" + +[linters] +enable = [ + "gocyclo", "unconvert", "goimports", "unused", + "vetshadow", "misspell", "nakedret", "errcheck", "revive", "ineffassign", + "goconst", "vet", "unparam", "gofmt" +] + +[issues] +exclude-use-default = false \ No newline at end of file diff --git a/README.md b/README.md index 32f2ca0..efe6344 100644 --- a/README.md +++ b/README.md @@ -126,14 +126,14 @@ gobl.nav convert ./test/data/invoice.json ## Limitations/Things to do +### Invoice Modification +- For invoice modification the only step left is to get the line number of the invoice that we want to modify and include it in the field `LineModificationReference` + ### Doc Conversion - Batch invoicing not supported -- Modification of invoices not supported - Support fiscal representatives - Aggregate invoices not supported - Product refund charges not supported (Field Product Fee Summary in the Invoice) - Nav supports 100 invoice creation/modification in the same request. For the moment, we only support 1 invoice at each request. -## Tags, Tax and Extensions - diff --git a/internal/doc/address.go b/internal/doc/address.go index 431d3ca..151245f 100644 --- a/internal/doc/address.go +++ b/internal/doc/address.go @@ -30,8 +30,8 @@ type DetailedAddress struct { // SimpleAddressType represents a simple address type SimpleAddress struct { - CountryCode string `xml:"countryCode"` - Region string `xml:"region,omitempty"` + CountryCode string `xml:"base:countryCode"` + Region string `xml:"base:region,omitempty"` PostalCode string `xml:"base:postalCode"` City string `xml:"base:city"` AdditionalAddressDetail string `xml:"base:additionalAddressDetail"` diff --git a/internal/doc/address_test.go b/internal/doc/address_test.go index 5e9bc87..6b6569c 100644 --- a/internal/doc/address_test.go +++ b/internal/doc/address_test.go @@ -17,15 +17,16 @@ func TestNewAddress(t *testing.T) { { name: "Detailed address with all fields", input: &org.Address{ - Country: l10n.HU.ISO(), - Region: "Budapest", - Code: "1234", - Locality: "Budapest", - Street: "Main Street", - Number: "10", - Block: "B", - Floor: "2", - Door: "5", + Country: l10n.HU.ISO(), + Region: "Budapest", + Code: "1234", + Locality: "Budapest", + Street: "Main", + StreetType: "street", + Number: "10", + Block: "B", + Floor: "2", + Door: "5", }, expectedOutput: &Address{ DetailedAddress: &DetailedAddress{ @@ -33,12 +34,12 @@ func TestNewAddress(t *testing.T) { Region: "Budapest", PostalCode: "1234", City: "Budapest", - StreetName: "Main Street", + StreetName: "Main", Number: "10", Building: "B", Floor: "2", Door: "5", - PublicPlaceCategory: "utca", + PublicPlaceCategory: "street", }, }, }, @@ -49,14 +50,14 @@ func TestNewAddress(t *testing.T) { Code: "1234", Locality: "Budapest", Street: "Main Street", + Number: "10", }, expectedOutput: &Address{ - DetailedAddress: &DetailedAddress{ - CountryCode: "HU", - PostalCode: "1234", - City: "Budapest", - StreetName: "Main Street", - PublicPlaceCategory: "utca", + SimpleAddress: &SimpleAddress{ + CountryCode: "HU", + PostalCode: "1234", + City: "Budapest", + AdditionalAddressDetail: "Main Street, 10", }, }, }, diff --git a/internal/doc/customer.go b/internal/doc/customer.go index deb0dfe..d45bcf4 100644 --- a/internal/doc/customer.go +++ b/internal/doc/customer.go @@ -6,6 +6,12 @@ import ( "github.com/invopop/gobl/tax" ) +const ( + VatStatusPrivatePerson = "PRIVATE_PERSON" + VatStatusDomestic = "DOMESTIC" + VatStatusOther = "OTHER" +) + // CustomerInfo contains the customer data. type CustomerInfo struct { CustomerVatStatus string `xml:"customerVatStatus"` // PRIVATE_PERSON, DOMESTIC, OTHER @@ -19,7 +25,7 @@ type CustomerInfo struct { type VatData struct { CustomerTaxNumber *CustomerTaxNumber `xml:"customerTaxNumber,omitempty"` CommunityVATNumber string `xml:"communityVATNumber,omitempty"` - ThirdStateTaxId string `xml:"thirdStateTaxId,omitempty"` + ThirdStateTaxID string `xml:"thirdStateTaxId,omitempty"` } // CustomerTaxNumber contains the domestic tax number or @@ -33,26 +39,25 @@ type CustomerTaxNumber struct { } func newCustomerInfo(customer *org.Party) (*CustomerInfo, error) { - taxID := customer.TaxID if taxID == nil { return &CustomerInfo{ - CustomerVatStatus: "OTHER", + CustomerVatStatus: VatStatusPrivatePerson, CustomerName: customer.Name, CustomerAddress: newAddress(customer.Addresses[0]), }, nil } - status := "OTHER" + status := VatStatusOther if taxID.Country == l10n.HU.Tax() { if taxID.Code.String() == "" || (taxID.Code.String()[0:1] == "8" && len(taxID.Code) == 10) { return &CustomerInfo{ - CustomerVatStatus: "PRIVATE_PERSON", + CustomerVatStatus: VatStatusPrivatePerson, CustomerName: customer.Name, CustomerAddress: newAddress(customer.Addresses[0]), }, nil } - status = "DOMESTIC" + status = VatStatusDomestic } @@ -70,7 +75,7 @@ func newCustomerInfo(customer *org.Party) (*CustomerInfo, error) { } func newVatData(customer *org.Party, status string) (*VatData, error) { - if status == "OTHER" { + if status == VatStatusOther { return newOtherVatData(customer.TaxID), nil } return newDomesticVatData(customer) @@ -83,7 +88,7 @@ func newOtherVatData(taxID *tax.Identity) *VatData { } } return &VatData{ - ThirdStateTaxId: taxID.String(), + ThirdStateTaxID: taxID.String(), } } diff --git a/internal/doc/customer_test.go b/internal/doc/customer_test.go index d68a368..66c40e1 100644 --- a/internal/doc/customer_test.go +++ b/internal/doc/customer_test.go @@ -31,7 +31,7 @@ func TestNewCustomerInfo(t *testing.T) { }, }, }, - expectedStatus: "OTHER", + expectedStatus: "PRIVATE_PERSON", expectedVatData: nil, expectedName: "John Doe", expectedErr: nil, @@ -137,7 +137,7 @@ func TestNewCustomerInfo(t *testing.T) { }, expectedStatus: "OTHER", expectedVatData: &VatData{ - ThirdStateTaxId: "US123456789", + ThirdStateTaxID: "US123456789", }, expectedName: "Non-EU Company", expectedErr: nil, diff --git a/internal/doc/detail.go b/internal/doc/detail.go index b9e353e..de5fef0 100644 --- a/internal/doc/detail.go +++ b/internal/doc/detail.go @@ -74,7 +74,7 @@ func newInvoiceDetail(inv *bill.Invoice) (*InvoiceDetail, error) { } } - rate, err := getInvoiceRate(inv) + rate, err := getExchangeRate(inv) if err != nil { return nil, err } @@ -92,7 +92,7 @@ func newInvoiceDetail(inv *bill.Invoice) (*InvoiceDetail, error) { }, nil } -func getInvoiceRate(inv *bill.Invoice) (num.Amount, error) { +func getExchangeRate(inv *bill.Invoice) (num.Amount, error) { if inv.Currency == currency.HUF { return num.MakeAmount(1, 0), nil } diff --git a/internal/doc/detail_test.go b/internal/doc/detail_test.go index 35e8b6b..6e65576 100644 --- a/internal/doc/detail_test.go +++ b/internal/doc/detail_test.go @@ -43,7 +43,7 @@ func TestNewInvoiceDetail_NormalInvoice(t *testing.T) { assert.Equal(t, "", detail.InvoiceDeliveryPeriodStart) assert.Equal(t, "", detail.InvoiceDeliveryPeriodEnd) assert.Equal(t, "USD", detail.CurrencyCode) - assert.Equal(t, 358.35432, detail.ExchangeRate) + assert.Equal(t, "358.354320", detail.ExchangeRate) assert.Equal(t, "CARD", detail.PaymentMethod) assert.Equal(t, "2023-08-20", detail.PaymentDate) assert.Equal(t, "EDI", detail.InvoiceAppearance) diff --git a/internal/doc/doc.go b/internal/doc/doc.go index ec8fcf9..f54b532 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -1,14 +1,19 @@ +// Package doc includes the conversion from GOBL to Nav XML format. package doc import ( "encoding/xml" "errors" "fmt" + "os" "github.com/invopop/gobl" "github.com/invopop/gobl/bill" + "github.com/lestrrat-go/libxml2" + "github.com/lestrrat-go/libxml2/xsd" ) +// XML schemas const ( XMNLSDATA = "http://schemas.nav.gov.hu/OSA/3.0/data" XMNLSCOMMON = "http://schemas.nav.gov.hu/NTCA/1.0/common" @@ -17,6 +22,8 @@ const ( XSIDataSchema = "http://schemas.nav.gov.hu/OSA/3.0/data invoiceData.xsd" ) +const BaseDirectory = "../../test/data/out/" + // Standard error responses. var ( ErrNoExchangeRate = newValidationError("no exchange rate to HUF found") @@ -58,15 +65,24 @@ func NewDocument(env *gobl.Envelope) (*Document, error) { if !ok { return nil, fmt.Errorf("invalid type %T", env.Document) } - d := new(Document) - d.XMLNS = XMNLSDATA - d.XMLNSXsi = XMNLXSI - d.XSISchema = XSIDataSchema - d.XMLNSCommon = XMNLSCOMMON - d.XMLNSBase = XMNLBASE - d.InvoiceNumber = inv.Code - d.InvoiceIssueDate = inv.IssueDate.String() - d.CompletenessIndicator = false + + // Invert if we're dealing with a credit note + if inv.Type == bill.InvoiceTypeCreditNote { + if err := inv.Invert(); err != nil { + return nil, fmt.Errorf("inverting invoice: %w", err) + } + } + + d := &Document{ + XMLNS: XMNLSDATA, + XMLNSXsi: XMNLXSI, + XSISchema: XSIDataSchema, + XMLNSCommon: XMNLSCOMMON, + XMLNSBase: XMNLBASE, + InvoiceNumber: inv.Code, + InvoiceIssueDate: inv.IssueDate.String(), + CompletenessIndicator: false, + } main, err := newInvoiceMain(inv) if err != nil { return nil, err @@ -74,3 +90,60 @@ func NewDocument(env *gobl.Envelope) (*Document, error) { d.InvoiceMain = main return d, nil } + +func schemaValidation(xmlData []byte) error { + + schema, err := loadSchema() + if err != nil { + return fmt.Errorf("error loading schema: %w", err) + } + + docXML, err := libxml2.ParseString(string(xmlData)) + if err != nil { + return fmt.Errorf("error parsing XML: %w", err) + } + defer docXML.Free() + + if err := schema.Validate(docXML); err != nil { + validationErrors := err.(xsd.SchemaValidationError) + fmt.Println("XML Validation Errors:") + for _, verr := range validationErrors.Errors() { + fmt.Printf("- %s\n", verr.Error()) + } + return fmt.Errorf("XML validation failed: %w", err) + } + + return nil +} + +func (d *Document) toByte() ([]byte, error) { + xmlData, err := xml.MarshalIndent(d, "", " ") + if err != nil { + return nil, fmt.Errorf("Error marshalling to XML: %w", err) + } + + return xmlData, nil +} + +func saveOutput(xmlData []byte, fileName string) error { + err := os.WriteFile(BaseDirectory+fileName, xmlData, 0644) + if err != nil { + return fmt.Errorf("Error writing XML to file: %w", err) + } + + return nil +} + +func loadSchema() (*xsd.Schema, error) { + xsdContent, err := os.ReadFile("../../test/schemas/invoiceData.xsd") + if err != nil { + return nil, fmt.Errorf("Error reading XSD file: %w", err) + } + + schema, err := xsd.Parse(xsdContent) + if err != nil { + return nil, fmt.Errorf("Error parsing XSD: %w", err) + } + + return schema, nil +} diff --git a/internal/doc/doc_test.go b/internal/doc/doc_test.go index c7dfe9e..9a92ba7 100644 --- a/internal/doc/doc_test.go +++ b/internal/doc/doc_test.go @@ -2,21 +2,18 @@ package doc import ( "encoding/json" - "encoding/xml" "fmt" "os" "testing" "github.com/invopop/gobl" - "github.com/lestrrat-go/libxml2" - "github.com/lestrrat-go/libxml2/xsd" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestNewDocument(t *testing.T) { +func TestStandardInvoice(t *testing.T) { // Read the test invoice JSON file - data, err := os.ReadFile("../../test/data/invoice_test.json") + data, err := os.ReadFile("../../test/data/invoice-standard.json") require.NoError(t, err, "Failed to read test invoice file") // Unmarshal the JSON into a gobl.Envelope @@ -28,58 +25,102 @@ func TestNewDocument(t *testing.T) { doc, err := NewDocument(env) require.NoError(t, err, "Failed to create new document") - xmlData, err := xml.MarshalIndent(doc, "", " ") - if err != nil { - fmt.Printf("Error marshalling to XML: %v\n", err) - return - } - - err = os.WriteFile("../../test/data/out/output.xml", xmlData, 0644) - if err != nil { - fmt.Println("Error writing XML to file:", err) - return - } - - // 2. Load XSD schema - xsdContent, err := os.ReadFile("../../test/schemas/invoiceData.xsd") - if err != nil { - fmt.Println("Error reading XSD file:", err) - return - } - - schema, err := xsd.Parse(xsdContent) - if err != nil { - fmt.Println("Error parsing XSD:", err) - return - } - defer schema.Free() - - // 3. Parse XML - docXML, err := libxml2.ParseString(string(xmlData)) - if err != nil { - fmt.Println("Error parsing XML:", err) - return - } - defer docXML.Free() - - // 4. Validate XML against schema - if err := schema.Validate(docXML); err != nil { - fmt.Println("Validation error:", err) - } else { - fmt.Println("XML is valid according to the schema") - } + xmlData, err := doc.toByte() + require.NoError(t, err, "Failed to marshal document to XML") + + err = saveOutput(xmlData, "invoice-standard.xml") + require.NoError(t, err, "Failed to save XML output") + + err = schemaValidation(xmlData) + require.NoError(t, err, "Failed to validate XML output") + + fmt.Println(string(xmlData)) + +} + +func TestCreditNote(t *testing.T) { + // Read the test invoice JSON file + data, err := os.ReadFile("../../test/data/credit-note.json") + require.NoError(t, err, "Failed to read test invoice file") + + // Unmarshal the JSON into a gobl.Envelope + env := new(gobl.Envelope) + err = json.Unmarshal(data, env) + require.NoError(t, err, "Failed to unmarshal test invoice JSON") + + // Call the NewDocument function + doc, err := NewDocument(env) + require.NoError(t, err, "Failed to create new document") + + xmlData, err := doc.toByte() + require.NoError(t, err, "Failed to marshal document to XML") + + err = saveOutput(xmlData, "credit-note.xml") + require.NoError(t, err, "Failed to save XML output") + + err = schemaValidation(xmlData) + require.NoError(t, err, "Failed to validate XML output") + + fmt.Println(string(xmlData)) + + assert.Equal(t, doc.InvoiceMain.Invoice.InvoiceLines.Lines[0].LineAmountsNormal.LineNetAmountData.LineNetAmount, "-600000.00", "lineNetAmount should be negative") + assert.Equal(t, doc.InvoiceMain.Invoice.InvoiceSummary.SummaryNormal.SummaryByVatRate[0].VatRateVatData.VatRateVatAmount, "-162000.00", "totalAmount should be negative") + +} + +func TestB2CInvoice(t *testing.T) { + // Read the test invoice JSON file + data, err := os.ReadFile("../../test/data/b2c.json") + require.NoError(t, err, "Failed to read test invoice file") + + // Unmarshal the JSON into a gobl.Envelope + env := new(gobl.Envelope) + err = json.Unmarshal(data, env) + require.NoError(t, err, "Failed to unmarshal test invoice JSON") + + // Call the NewDocument function + doc, err := NewDocument(env) + require.NoError(t, err, "Failed to create new document") + + xmlData, err := doc.toByte() + require.NoError(t, err, "Failed to marshal document to XML") + + err = saveOutput(xmlData, "b2c.xml") + require.NoError(t, err, "Failed to save XML output") + + err = schemaValidation(xmlData) + require.NoError(t, err, "Failed to validate XML output") fmt.Println(string(xmlData)) - // Assert the expected values - assert.Equal(t, XMNLSDATA, doc.XMLNS, "Unexpected XMLNS value") - assert.Equal(t, XMNLXSI, doc.XMLNSXsi, "Unexpected XMLNSXsi value") - assert.Equal(t, XSIDataSchema, doc.XSISchema, "Unexpected XSISchema value") - assert.Equal(t, XMNLSCOMMON, doc.XMLNSCommon, "Unexpected XMLNSCommon value") - assert.Equal(t, XMNLBASE, doc.XMLNSBase, "Unexpected XMLNSBase value") - assert.False(t, doc.CompletenessIndicator, "Unexpected CompletenessIndicator value") + assert.Equal(t, doc.InvoiceMain.Invoice.InvoiceHead.CustomerInfo.CustomerVatStatus, "PRIVATE_PERSON", "customerVatStatus should be PRIVATE_PERSON") +} - // Assert that InvoiceMain is not nil - assert.NotNil(t, doc.InvoiceMain, "InvoiceMain should not be nil") +func TestForeignInvoice(t *testing.T) { + // Read the test invoice JSON file + data, err := os.ReadFile("../../test/data/foreign.json") + require.NoError(t, err, "Failed to read test invoice file") + + // Unmarshal the JSON into a gobl.Envelope + env := new(gobl.Envelope) + err = json.Unmarshal(data, env) + require.NoError(t, err, "Failed to unmarshal test invoice JSON") + + // Call the NewDocument function + doc, err := NewDocument(env) + require.NoError(t, err, "Failed to create new document") + + xmlData, err := doc.toByte() + require.NoError(t, err, "Failed to marshal document to XML") + + err = saveOutput(xmlData, "foreign.xml") + require.NoError(t, err, "Failed to save XML output") + + err = schemaValidation(xmlData) + require.NoError(t, err, "Failed to validate XML output") + + fmt.Println(string(xmlData)) + assert.Equal(t, doc.InvoiceMain.Invoice.InvoiceLines.Lines[0].LineAmountsNormal.LineNetAmountData.LineNetAmount, "2000.00", "lineNetAmount should be 2000.00") + assert.NotNil(t, doc.InvoiceMain.Invoice.InvoiceHead.CustomerInfo.CustomerVatData.ThirdStateTaxID, "thirdStateTaxID should not be nil") } diff --git a/internal/doc/invoice.go b/internal/doc/invoice.go index ea30824..c1db992 100644 --- a/internal/doc/invoice.go +++ b/internal/doc/invoice.go @@ -12,35 +12,39 @@ type InvoiceMain struct { // Invoice is the main invoice data structure. type Invoice struct { - //InvoiceReference InvoiceReference `xml:"invoiceReference,omitempty"` // Used for invoice modification (reference other invoice) - InvoiceHead *InvoiceHead `xml:"invoiceHead"` - InvoiceLines *InvoiceLines `xml:"invoiceLines,omitempty"` + InvoiceReference *InvoiceReference `xml:"invoiceReference,omitempty"` // Used for invoice modification (reference other invoice) + InvoiceHead *InvoiceHead `xml:"invoiceHead"` + InvoiceLines *InvoiceLines `xml:"invoiceLines,omitempty"` //ProductFeeSummary ProductFeeSummary `xml:"productFeeSummary,omitempty"` InvoiceSummary *InvoiceSummary `xml:"invoiceSummary"` } func newInvoiceMain(inv *bill.Invoice) (*InvoiceMain, error) { + invoice := &Invoice{} + + if inv.Preceding != nil { + invoice.InvoiceReference = newInvoiceReference(inv.Preceding[0]) + } invoiceHead, err := newInvoiceHead(inv) if err != nil { return nil, err } + invoice.InvoiceHead = invoiceHead invoiceLines, err := newInvoiceLines(inv) if err != nil { return nil, err } + invoice.InvoiceLines = invoiceLines invoiceSummary, err := newInvoiceSummary(inv) if err != nil { return nil, err } + invoice.InvoiceSummary = invoiceSummary return &InvoiceMain{ - Invoice: &Invoice{ - InvoiceHead: invoiceHead, - InvoiceLines: invoiceLines, - InvoiceSummary: invoiceSummary, - }, + Invoice: invoice, }, nil } diff --git a/internal/doc/lines.go b/internal/doc/lines.go index 3677ac0..6e22060 100644 --- a/internal/doc/lines.go +++ b/internal/doc/lines.go @@ -112,7 +112,7 @@ var validUnits = map[org.Unit]string{ func newInvoiceLines(inv *bill.Invoice) (*InvoiceLines, error) { invoiceLines := &InvoiceLines{} taxinfo := newTaxInfo(inv) - rate, err := getInvoiceRate(inv) + rate, err := getExchangeRate(inv) if err != nil { return nil, err } diff --git a/internal/doc/lines_test.go b/internal/doc/lines_test.go index 66d1309..17a690f 100644 --- a/internal/doc/lines_test.go +++ b/internal/doc/lines_test.go @@ -57,8 +57,8 @@ func TestNewInvoiceLines(t *testing.T) { assert.Equal(t, 1, line.LineNumber) assert.Equal(t, "Test Product", line.LineDescription) assert.Equal(t, "PRODUCT", line.LineNatureIndicator) - assert.Equal(t, 2.0, line.Quantity) - assert.Equal(t, 100.00, line.UnitPrice) + assert.Equal(t, "2", line.Quantity) + assert.Equal(t, "100.00", line.UnitPrice) assert.Equal(t, "PIECE", line.UnitOfMeasure) // Check Product Codes @@ -70,11 +70,11 @@ func TestNewInvoiceLines(t *testing.T) { // Check Discount Data assert.NotNil(t, line.LineDiscountData) assert.Equal(t, "Seasonal Discount. ", line.LineDiscountData.DiscountDescription) - assert.Equal(t, 5.00, line.LineDiscountData.DiscountValue) + assert.Equal(t, "5.00", line.LineDiscountData.DiscountValue) // Check VAT and Amounts assert.NotNil(t, line.LineAmountsNormal) - assert.Equal(t, 195.00, line.LineAmountsNormal.LineNetAmountData.LineNetAmount) + assert.Equal(t, "195.00", line.LineAmountsNormal.LineNetAmountData.LineNetAmount) } func TestNewLine_SimplifiedInvoice(t *testing.T) { @@ -111,11 +111,11 @@ func TestNewLine_SimplifiedInvoice(t *testing.T) { // Check line data for a simplified invoice assert.Equal(t, "Simplified Service", line.LineDescription) assert.Equal(t, "SERVICE", line.LineNatureIndicator) - assert.Equal(t, 3.0, line.Quantity) - assert.Equal(t, 100.00, line.UnitPrice) + assert.Equal(t, "3", line.Quantity) + assert.Equal(t, "100.00", line.UnitPrice) assert.Equal(t, "HOUR", line.UnitOfMeasure) // Check VAT and Amounts assert.NotNil(t, line.LineAmountsSimplified) - assert.Equal(t, 300.00, line.LineAmountsSimplified.LineGrossAmountSimplified) + assert.Equal(t, "300.00", line.LineAmountsSimplified.LineGrossAmountSimplified) } diff --git a/internal/doc/reference.go b/internal/doc/reference.go new file mode 100644 index 0000000..708d860 --- /dev/null +++ b/internal/doc/reference.go @@ -0,0 +1,24 @@ +package doc + +import "github.com/invopop/gobl/bill" + +// InvoiceReference is used for invoice modification (reference other invoice) +type InvoiceReference struct { + OriginalInvoiceNumber string `xml:"originalInvoiceNumber"` + ModifyWithoutMaster bool `xml:"modifyWithoutMaster"` + ModificationIndex string `xml:"modificationIndex"` +} + +func newInvoiceReference(reference *bill.Preceding) *InvoiceReference { + return &InvoiceReference{ + OriginalInvoiceNumber: reference.Code, + ModifyWithoutMaster: false, + ModificationIndex: reference.Series, + } +} + +/* +ModifyWithoutMaster value is only true in specific cases: +See the p.99 of https://onlineszamla-test.nav.gov.hu/files/container/download/Online%20Invoice%20System%203.0%20Interface%20Specification.pdf +This can be handled with an extension code +*/ diff --git a/internal/doc/summary.go b/internal/doc/summary.go index 1b4a0bb..bd445e9 100644 --- a/internal/doc/summary.go +++ b/internal/doc/summary.go @@ -61,7 +61,7 @@ func newInvoiceSummary(inv *bill.Invoice) (*InvoiceSummary, error) { totalVat := num.MakeAmount(0, 5) summaryVat := []*SummaryByVatRate{} taxInfo := newTaxInfo(inv) - ex, err := getInvoiceRate(inv) + ex, err := getExchangeRate(inv) if err != nil { return nil, err diff --git a/internal/doc/vatrate.go b/internal/doc/vatrate.go index 7c3ca73..28d75e4 100644 --- a/internal/doc/vatrate.go +++ b/internal/doc/vatrate.go @@ -61,14 +61,13 @@ func newVatRateTotal(rate *tax.RateTotal, info *taxInfo) (*VatRate, error) { return &VatRate{VatContent: rate.Amount.Rescale(2).String()}, nil } - // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode - for k, v := range rate.Ext { - if k == hu.ExtKeyExemptionCode { - return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}}, nil - } - - if k == hu.ExtKeyVatOutOfScopeCode { - return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}}, nil + value, exists := rate.Ext[hu.ExtKeyExemptionCode] + if exists { + switch value.String() { + case "AAM", "TAM", "KBAET", "KBAUK", "EAM", "NAM", "UNKNOWKN": + return &VatRate{VatExemption: &DetailedReason{Case: value.String(), Reason: "Exempt"}}, nil + case "ATK", "EUFAD37", "EUE", "HO": + return &VatRate{VatOutOfScope: &DetailedReason{Case: value.String(), Reason: "Out of Scope"}}, nil } } @@ -109,14 +108,13 @@ func newVatRateCombo(c *tax.Combo, info *taxInfo) (*VatRate, error) { return &VatRate{VatPercentage: c.Percent.Base().String()}, nil } - // Check if in the rate extensions there is extkeyexemptioncode or extkeyvatoutofscopecode - for k, v := range c.Ext { - if k == hu.ExtKeyExemptionCode { - return &VatRate{VatExemption: &DetailedReason{Case: v.String(), Reason: "Exempt"}}, nil - } - - if k == hu.ExtKeyVatOutOfScopeCode { - return &VatRate{VatOutOfScope: &DetailedReason{Case: v.String(), Reason: "Out of Scope"}}, nil + value, exists := c.Ext[hu.ExtKeyExemptionCode] + if exists { + switch value.String() { + case "AAM", "TAM", "KBAET", "KBAUK", "EAM", "NAM", "UNKNOWKN": + return &VatRate{VatExemption: &DetailedReason{Case: value.String(), Reason: "Exempt"}}, nil + case "ATK", "EUFAD37", "EUE", "HO": + return &VatRate{VatOutOfScope: &DetailedReason{Case: value.String(), Reason: "Out of Scope"}}, nil } } diff --git a/internal/doc/vatrate_test.go b/internal/doc/vatrate_test.go index 63e93dc..551b984 100644 --- a/internal/doc/vatrate_test.go +++ b/internal/doc/vatrate_test.go @@ -19,7 +19,7 @@ func TestNewVatRate(t *testing.T) { { name: "RateTotal with Percentage", input: &tax.RateTotal{ - Percent: num.NewPercentage(27, 4), + Percent: num.NewPercentage(27, 2), }, info: &taxInfo{}, expected: &VatRate{ @@ -58,7 +58,7 @@ func TestNewVatRate(t *testing.T) { name: "RateTotal with Out of Scope Code", input: &tax.RateTotal{ Ext: tax.Extensions{ - "hu-vat-out-of-scope-code": "ATK", + "hu-exemption-code": "ATK", }, }, info: &taxInfo{}, diff --git a/internal/gateways/invoice.go b/internal/gateways/invoice.go index b3db07c..8af1197 100644 --- a/internal/gateways/invoice.go +++ b/internal/gateways/invoice.go @@ -47,18 +47,20 @@ type ManageInvoiceResponse struct { TransactionId string `xml:"transactionId"` } -func (g *Client) ReportInvoice(invoice []byte) (string, error) { +func (g *Client) ReportInvoice(invoice []byte, operationType string) (string, error) { // We first fetch the exchange token - g.GetToken() + err := g.GetToken() + if err != nil { + return "", err + } encodedInvoice := base64.StdEncoding.EncodeToString(invoice) - requestData := g.newManageInvoiceRequest(encodedInvoice) + requestData := g.newManageInvoiceRequest(encodedInvoice, operationType) return g.postManageInvoiceRequest(requestData) } -func (g *Client) newManageInvoiceRequest(invoice string) ManageInvoiceRequest { +func (g *Client) newManageInvoiceRequest(invoice string, operationType string) ManageInvoiceRequest { timestamp := time.Now().UTC() requestID := NewRequestID(timestamp) - operationType := "CREATE" // For the moment, only CREATE is supported return ManageInvoiceRequest{ Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", diff --git a/internal/gateways/status_test.go b/internal/gateways/status_test.go index 10b92f8..d90f43a 100644 --- a/internal/gateways/status_test.go +++ b/internal/gateways/status_test.go @@ -22,7 +22,7 @@ func TestQueryTransactionStatus(t *testing.T) { "ONLINE_SERVICE", "1.0.0", "TestDev", - "pablo.menendez@invopop.com", + "test@dev.com", ) err := godotenv.Load("../../.env") if err != nil { @@ -39,7 +39,7 @@ func TestQueryTransactionStatus(t *testing.T) { client := New(user, software, Environment("testing")) - result, err := client.GetStatus("4P0TNGVKJM2HV0C3") + result, err := client.GetStatus("4P2PEVFLLNKYTV3I") // Assert the results assert.NoError(t, err) diff --git a/internal/gateways/token.go b/internal/gateways/token.go index 7d9749f..25bfa41 100644 --- a/internal/gateways/token.go +++ b/internal/gateways/token.go @@ -73,7 +73,10 @@ func (g *Client) postTokenExchangeRequest(requestData TokenExchangeRequest) (*To if err != nil { expirationTime, err = time.Parse("2006-01-02T15:04:05.00Z", tokenExchangeResponse.TokenValidityTo) if err != nil { - return nil, err + expirationTime, err = time.Parse("2006-01-02T15:04:05.0Z", tokenExchangeResponse.TokenValidityTo) + if err != nil { + return nil, err + } } } diff --git a/nav.go b/nav.go index 749c56e..05fd61a 100644 --- a/nav.go +++ b/nav.go @@ -1,3 +1,4 @@ +// Package nav is the package used to interact with the NAV API package nav import ( @@ -11,13 +12,17 @@ import ( "github.com/invopop/gobl/tax" ) +// Nav is the main struct for interacting with the NAV API type Nav struct { gw *gateways.Client env gateways.Environment } +// Option is a function used for the different options of the Nav client +// For the moment, the only option is the environment (production or testing) type Option func(*Nav) +// NewNav creates a new Nav client func NewNav(user *gateways.User, software *gateways.Software, opts ...Option) *Nav { c := new(Nav) @@ -45,16 +50,19 @@ func InTesting() Option { } } +// FetchToken fetches the token from the NAV API func (n *Nav) FetchToken() error { return n.gw.GetToken() } -func (n *Nav) ReportInvoice(invoice []byte) (string, error) { - return n.gw.ReportInvoice(invoice) +// ReportInvoice reports an invoice to the NAV API +func (n *Nav) ReportInvoice(invoice []byte, operationType string) (string, error) { + return n.gw.ReportInvoice(invoice, operationType) } -func (n *Nav) GetTransactionStatus(transactionId string) ([]*gateways.ProcessingResult, error) { - return n.gw.GetStatus(transactionId) +// GetTransactionStatus gets the status of an invoice reporting transaction +func (n *Nav) GetTransactionStatus(transactionID string) ([]*gateways.ProcessingResult, error) { + return n.gw.GetStatus(transactionID) } // NewSoftware creates a new Software with the information about the software developer @@ -67,6 +75,7 @@ func NewUser(login string, password string, signKey string, exchangeKey string, return gateways.NewUser(login, password, signKey, exchangeKey, taxNumber) } +// NewDocument creates a new Nav Document from a GOBL envelope func NewDocument(env *gobl.Envelope) (*doc.Document, error) { return doc.NewDocument(env) } diff --git a/nav_test.go b/nav_test.go index 84926c4..6bb08e3 100644 --- a/nav_test.go +++ b/nav_test.go @@ -40,25 +40,19 @@ func TestReportInvoice(t *testing.T) { navClient := NewNav(user, software, InTesting()) - invoice, err := os.ReadFile("test/data/out/output.xml") + invoice, err := os.ReadFile("test/data/out/credit-note.xml") if err != nil { t.Fatalf("Failed to read sample invoice file: %v", err) } - transactionId, err := navClient.ReportInvoice(invoice) + transactionID, err := navClient.ReportInvoice(invoice, "MODIFY") - fmt.Println("Transaction ID: ", transactionId) + fmt.Println("Transaction ID: ", transactionID) // Assert the result - if err != nil { - t.Errorf("ReportInvoice returned an unexpected error: %v", err) - } require.NoError(t, err, "Expected no error") - resultsList, err := navClient.GetTransactionStatus(transactionId) - if err != nil { - t.Errorf("GetTransactionStatus returned an unexpected error: %v", err) - } + resultsList, err := navClient.GetTransactionStatus(transactionID) require.NoError(t, err, "Expected no error") // Print result in xml format for debugging diff --git a/test/data/b2c.json b/test/data/b2c.json new file mode 100644 index 0000000..6652ec3 --- /dev/null +++ b/test/data/b2c.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "9b62fd40-3b38-11ee-be56-0242ac120003", + "dig": { + "alg": "sha256", + "val": "fb3e81ee5d0964fa423dcfb62309a7a5c5150dc62cdd81427a68a2c85e893a66" + }, + "draft": true + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "type": "standard", + "series": "SAMPLE", + "code": "009", + "issue_date": "2024-08-23", + "currency": "HUF", + "supplier": { + "name": "Provide One Kft.", + "tax_id": { + "country": "HU", + "code": "12345678215" + }, + "addresses": [ + { + "num": "16", + "street": "Andrássy út", + "locality": "Budapest", + "code": "1061", + "country": "HU" + } + ] + }, + "customer": { + "name": "Minta Fogyasztó", + "addresses": [ + { + "num": "25", + "street": "Váci utca", + "locality": "Budapest", + "code": "1052", + "country": "HU" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Fejlesztési szolgáltatások", + "price": "30000.00", + "unit": "h" + }, + "sum": "600000.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "27%" + } + ], + "total": "600000.00" + } + ], + "payment": { + "instructions": { + "key": "credit-transfer" + } + }, + "totals": { + "sum": "600000.00", + "total": "600000.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "600000.00", + "percent": "27%", + "amount": "162000.00" + } + ], + "amount": "162000.00" + } + ], + "sum": "162000.00" + }, + "tax": "162000.00", + "total_with_tax": "762000.00", + "payable": "762000.00" + } + } +} \ No newline at end of file diff --git a/test/data/invoice-test.json b/test/data/credit-note.json similarity index 94% rename from test/data/invoice-test.json rename to test/data/credit-note.json index 2c65a17..d280cd8 100644 --- a/test/data/invoice-test.json +++ b/test/data/credit-note.json @@ -10,11 +10,17 @@ }, "doc": { "$schema": "https://gobl.org/draft-0/bill/invoice", - "type": "standard", + "type": "credit-note", "series": "SAMPLE", - "code": "001", - "issue_date": "2024-02-13", + "code": "008", + "issue_date": "2024-08-23", "currency": "HUF", + "preceding": [ + { + "code": "007", + "series": "1" + } + ], "supplier": { "name": "Provide One Kft.", "tax_id": { diff --git a/test/data/foreign.json b/test/data/foreign.json new file mode 100644 index 0000000..feea41b --- /dev/null +++ b/test/data/foreign.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "9b62fd40-3b38-11ee-be56-0242ac120003", + "dig": { + "alg": "sha256", + "val": "fb3e81ee5d0964fa423dcfb62309a7a5c5150dc62cdd81427a68a2c85e893a66" + }, + "draft": true + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "type": "standard", + "series": "SAMPLE", + "code": "010", + "issue_date": "2024-08-23", + "currency": "USD", + "exchange_rates": [ + { + "from": "USD", + "to": "HUF", + "amount": "300" + } + ], + "supplier": { + "name": "Provide One Kft.", + "tax_id": { + "country": "HU", + "code": "12345678515" + }, + "identities": [ + { + "code": "12354678415" + } + ], + "addresses": [ + { + "num": "16", + "street": "Andrássy út", + "locality": "Budapest", + "code": "1061", + "country": "HU" + } + ] + }, + "customer": { + "name": "John Doe Company", + "tax_id": { + "country": "US", + "code": "87654321" + }, + "addresses": [ + { + "num": "25", + "street": "Váci utca", + "locality": "Budapest", + "code": "1052", + "country": "HU" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Fejlesztési szolgáltatások", + "price": "100.00", + "unit": "h" + }, + "sum": "2000.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "27%" + } + ], + "total": "2000.00" + } + ], + "payment": { + "instructions": { + "key": "credit-transfer" + } + }, + "totals": { + "sum": "2000.00", + "total": "2000.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "2000.00", + "percent": "27%", + "amount": "540.00" + } + ], + "amount": "540.00" + } + ], + "sum": "540.00" + }, + "tax": "540.00", + "total_with_tax": "2540.00", + "payable": "2540.00" + } + } +} \ No newline at end of file diff --git a/test/data/invoice-standard.json b/test/data/invoice-standard.json new file mode 100644 index 0000000..e669b2b --- /dev/null +++ b/test/data/invoice-standard.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://gobl.org/draft-0/envelope", + "head": { + "uuid": "9b62fd40-3b38-11ee-be56-0242ac120003", + "dig": { + "alg": "sha256", + "val": "fb3e81ee5d0964fa423dcfb62309a7a5c5150dc62cdd81427a68a2c85e893a66" + }, + "draft": true + }, + "doc": { + "$schema": "https://gobl.org/draft-0/bill/invoice", + "type": "standard", + "series": "SAMPLE", + "code": "007", + "issue_date": "2024-08-23", + "currency": "HUF", + "supplier": { + "name": "Provide One Kft.", + "tax_id": { + "country": "HU", + "code": "12345678215" + }, + "addresses": [ + { + "num": "16", + "street": "Andrássy út", + "locality": "Budapest", + "code": "1061", + "country": "HU" + } + ] + }, + "customer": { + "name": "Minta Fogyasztó", + "tax_id": { + "country": "HU", + "code": "87654321" + }, + "addresses": [ + { + "num": "25", + "street": "Váci utca", + "locality": "Budapest", + "code": "1052", + "country": "HU" + } + ] + }, + "lines": [ + { + "i": 1, + "quantity": "20", + "item": { + "name": "Fejlesztési szolgáltatások", + "price": "30000.00", + "unit": "h" + }, + "sum": "600000.00", + "taxes": [ + { + "cat": "VAT", + "rate": "standard", + "percent": "27%" + } + ], + "total": "600000.00" + } + ], + "payment": { + "instructions": { + "key": "credit-transfer" + } + }, + "totals": { + "sum": "600000.00", + "total": "600000.00", + "taxes": { + "categories": [ + { + "code": "VAT", + "rates": [ + { + "key": "standard", + "base": "600000.00", + "percent": "27%", + "amount": "162000.00" + } + ], + "amount": "162000.00" + } + ], + "sum": "162000.00" + }, + "tax": "162000.00", + "total_with_tax": "762000.00", + "payable": "762000.00" + } + } +} \ No newline at end of file diff --git a/test/data/out/b2c.xml b/test/data/out/b2c.xml new file mode 100644 index 0000000..7eeb0a4 --- /dev/null +++ b/test/data/out/b2c.xml @@ -0,0 +1,89 @@ + + 009 + 2024-08-23 + false + + + + + + 12345678 + 2 + 15 + + Provide One Kft. + + + HU + 1061 + Budapest + Andrássy út, 16 + + + + + PRIVATE_PERSON + Minta Fogyasztó + + + HU + 1052 + Budapest + Váci utca, 25 + + + + + NORMAL + 2024-08-23 + HUF + 1 + TRANSFER + EDI + + + + false + + 1 + true + Fejlesztési szolgáltatások + 20 + HOUR + 30000.00 + 30000.00 + + + 600000.00 + 600000.00 + + + 0.27 + + + + + + + + + 0.27 + + + 600000.00 + 600000.00 + + + 162000.00 + 162000.00 + + + 600000.00 + 600000.00 + 162000.00 + 162000.00 + + + + + \ No newline at end of file diff --git a/test/data/out/credit-note.xml b/test/data/out/credit-note.xml new file mode 100644 index 0000000..6bb69fa --- /dev/null +++ b/test/data/out/credit-note.xml @@ -0,0 +1,96 @@ + + 008 + 2024-08-23 + false + + + + 007 + false + 1 + + + + + 12345678 + + Provide One Kft. + + + HU + 1061 + Budapest + Andrássy út, 16 + + + + + DOMESTIC + + + 87654321 + + + Minta Fogyasztó + + + HU + 1052 + Budapest + Váci utca, 25 + + + + + NORMAL + 2024-08-23 + HUF + 1 + EDI + + + + false + + 1 + true + Fejlesztési szolgáltatások + -20 + HOUR + 30000.00 + 30000.00 + + + -600000.00 + -600000.00 + + + 0.27 + + + + + + + + + 0.27 + + + -600000.00 + -600000.00 + + + -162000.00 + -162000.00 + + + -600000.00 + -600000.00 + -162000.00 + -162000.00 + + + + + \ No newline at end of file diff --git a/examples/example.xml b/test/data/out/example.xml similarity index 100% rename from examples/example.xml rename to test/data/out/example.xml diff --git a/test/data/out/foreign.xml b/test/data/out/foreign.xml new file mode 100644 index 0000000..dc8a9c6 --- /dev/null +++ b/test/data/out/foreign.xml @@ -0,0 +1,97 @@ + + 010 + 2024-08-23 + false + + + + + + 12345678 + 5 + 15 + + + 12354678 + 4 + 15 + + Provide One Kft. + + + HU + 1061 + Budapest + Andrássy út, 16 + + + + + OTHER + + US87654321 + + John Doe Company + + + HU + 1052 + Budapest + Váci utca, 25 + + + + + NORMAL + 2024-08-23 + USD + 300.000000 + TRANSFER + EDI + + + + false + + 1 + true + Fejlesztési szolgáltatások + 20 + HOUR + 100.00 + 30000.00 + + + 2000.00 + 600000.00 + + + 0.27 + + + + + + + + + 0.27 + + + 2000.00 + 600000.00 + + + 540.00 + 162000.00 + + + 2000.00 + 600000.00 + 540.00 + 162000.00 + + + + + \ No newline at end of file diff --git a/test/data/out/output.xml b/test/data/out/invoice-standard.xml similarity index 83% rename from test/data/out/output.xml rename to test/data/out/invoice-standard.xml index aaab409..8b61c82 100644 --- a/test/data/out/output.xml +++ b/test/data/out/invoice-standard.xml @@ -1,6 +1,6 @@ - 001 - 2024-02-13 + 07 + 2024-08-23 false @@ -8,17 +8,17 @@ 12345678 + 2 + 15 Provide One Kft. - + HU 1061 Budapest - Andrássy út - utca - 16 - + Andrássy út, 16 + @@ -30,21 +30,20 @@ Minta Fogyasztó - + HU 1052 Budapest - Váci utca - utca - 25 - + Váci utca, 25 + NORMAL - 2024-02-13 + 2024-08-23 HUF 1 + TRANSFER EDI From 3b0dadccde6392ff3628de2db48c2fdd2d813f03 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Fri, 30 Aug 2024 17:54:20 +0000 Subject: [PATCH 22/27] Linting --- internal/doc/address.go | 4 ++-- internal/doc/customer.go | 44 +++++++++++++++------------------- internal/doc/customer_test.go | 5 +--- internal/doc/doc.go | 4 ++-- internal/doc/head.go | 10 ++------ internal/doc/lines.go | 2 +- internal/doc/summary.go | 7 +++++- internal/doc/supplier.go | 12 ++++------ internal/doc/supplier_test.go | 7 ++---- internal/doc/taxnumber.go | 10 ++++---- internal/doc/taxnumber_test.go | 5 +--- internal/gateways/common.go | 20 ++++++++-------- internal/gateways/gateways.go | 5 +++- internal/gateways/invoice.go | 8 ++++--- internal/gateways/status.go | 16 ++++++++++--- internal/gateways/token.go | 13 ++++++---- nav_test.go | 4 ++-- 17 files changed, 89 insertions(+), 87 deletions(-) diff --git a/internal/doc/address.go b/internal/doc/address.go index 151245f..2be102e 100644 --- a/internal/doc/address.go +++ b/internal/doc/address.go @@ -12,7 +12,7 @@ type Address struct { DetailedAddress *DetailedAddress `xml:"base:detailedAddress,omitempty"` } -// DetailedAddressType represents detailed address data +// DetailedAddress represents the address with all the details type DetailedAddress struct { CountryCode string `xml:"base:countryCode"` Region string `xml:"base:region,omitempty"` @@ -28,7 +28,7 @@ type DetailedAddress struct { LotNumber string `xml:"base:lotNumber,omitempty"` } -// SimpleAddressType represents a simple address +// SimpleAddress represents an address without detailed information type SimpleAddress struct { CountryCode string `xml:"base:countryCode"` Region string `xml:"base:region,omitempty"` diff --git a/internal/doc/customer.go b/internal/doc/customer.go index d45bcf4..3146d8c 100644 --- a/internal/doc/customer.go +++ b/internal/doc/customer.go @@ -7,9 +7,9 @@ import ( ) const ( - VatStatusPrivatePerson = "PRIVATE_PERSON" - VatStatusDomestic = "DOMESTIC" - VatStatusOther = "OTHER" + vatStatusPrivatePerson = "PRIVATE_PERSON" + vatStatusDomestic = "DOMESTIC" + vatStatusOther = "OTHER" ) // CustomerInfo contains the customer data. @@ -38,45 +38,42 @@ type CustomerTaxNumber struct { GroupMemberTaxNumber *TaxNumber `xml:"groupMemberTaxNumber,omitempty"` } -func newCustomerInfo(customer *org.Party) (*CustomerInfo, error) { +func newCustomerInfo(customer *org.Party) *CustomerInfo { taxID := customer.TaxID if taxID == nil { return &CustomerInfo{ - CustomerVatStatus: VatStatusPrivatePerson, + CustomerVatStatus: vatStatusPrivatePerson, CustomerName: customer.Name, CustomerAddress: newAddress(customer.Addresses[0]), - }, nil + } } - status := VatStatusOther + status := vatStatusOther if taxID.Country == l10n.HU.Tax() { if taxID.Code.String() == "" || (taxID.Code.String()[0:1] == "8" && len(taxID.Code) == 10) { return &CustomerInfo{ - CustomerVatStatus: VatStatusPrivatePerson, + CustomerVatStatus: vatStatusPrivatePerson, CustomerName: customer.Name, CustomerAddress: newAddress(customer.Addresses[0]), - }, nil + } } - status = VatStatusDomestic + status = vatStatusDomestic } - vatData, err := newVatData(customer, status) - if err != nil { - return nil, err - } + vatData := newVatData(customer, status) return &CustomerInfo{ CustomerVatStatus: status, CustomerVatData: vatData, CustomerName: customer.Name, CustomerAddress: newAddress(customer.Addresses[0]), - }, nil + } } -func newVatData(customer *org.Party, status string) (*VatData, error) { - if status == VatStatusOther { - return newOtherVatData(customer.TaxID), nil +func newVatData(customer *org.Party, status string) *VatData { + if status == vatStatusOther { + return newOtherVatData(customer.TaxID) } return newDomesticVatData(customer) } @@ -92,11 +89,8 @@ func newOtherVatData(taxID *tax.Identity) *VatData { } } -func newDomesticVatData(customer *org.Party) (*VatData, error) { - taxNumber, groupNumber, err := newTaxNumber(customer) - if err != nil { - return nil, err - } +func newDomesticVatData(customer *org.Party) *VatData { + taxNumber, groupNumber := newTaxNumber(customer) if groupNumber != nil { return &VatData{ @@ -106,7 +100,7 @@ func newDomesticVatData(customer *org.Party) (*VatData, error) { CountyCode: taxNumber.CountyCode, GroupMemberTaxNumber: groupNumber, }, - }, nil + } } return &VatData{ @@ -115,7 +109,7 @@ func newDomesticVatData(customer *org.Party) (*VatData, error) { VatCode: taxNumber.VatCode, CountyCode: taxNumber.CountyCode, }, - }, nil + } } var europeanCountryCodes = []l10n.Code{ diff --git a/internal/doc/customer_test.go b/internal/doc/customer_test.go index 66c40e1..614967d 100644 --- a/internal/doc/customer_test.go +++ b/internal/doc/customer_test.go @@ -146,11 +146,8 @@ func TestNewCustomerInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - customerInfo, err := newCustomerInfo(tt.customer) + customerInfo := newCustomerInfo(tt.customer) - if err != tt.expectedErr { - t.Errorf("expected error %v, got %v", tt.expectedErr, err) - } if customerInfo.CustomerVatStatus != tt.expectedStatus { t.Errorf("expected status %v, got %v", tt.expectedStatus, customerInfo.CustomerVatStatus) } diff --git a/internal/doc/doc.go b/internal/doc/doc.go index f54b532..17b7021 100644 --- a/internal/doc/doc.go +++ b/internal/doc/doc.go @@ -22,7 +22,7 @@ const ( XSIDataSchema = "http://schemas.nav.gov.hu/OSA/3.0/data invoiceData.xsd" ) -const BaseDirectory = "../../test/data/out/" +const baseDirectory = "../../test/data/out/" // Standard error responses. var ( @@ -126,7 +126,7 @@ func (d *Document) toByte() ([]byte, error) { } func saveOutput(xmlData []byte, fileName string) error { - err := os.WriteFile(BaseDirectory+fileName, xmlData, 0644) + err := os.WriteFile(baseDirectory+fileName, xmlData, 0644) if err != nil { return fmt.Errorf("Error writing XML to file: %w", err) } diff --git a/internal/doc/head.go b/internal/doc/head.go index 7131e4e..764a8ca 100644 --- a/internal/doc/head.go +++ b/internal/doc/head.go @@ -12,15 +12,9 @@ type InvoiceHead struct { } func newInvoiceHead(inv *bill.Invoice) (*InvoiceHead, error) { - supplierInfo, err := newSupplierInfo(inv.Supplier) - if err != nil { - return nil, err - } + supplierInfo := newSupplierInfo(inv.Supplier) - customerInfo, err := newCustomerInfo(inv.Customer) - if err != nil { - return nil, err - } + customerInfo := newCustomerInfo(inv.Customer) detail, err := newInvoiceDetail(inv) if err != nil { diff --git a/internal/doc/lines.go b/internal/doc/lines.go index 6e22060..e745111 100644 --- a/internal/doc/lines.go +++ b/internal/doc/lines.go @@ -80,7 +80,7 @@ type LineNetAmountData struct { LineNetAmountHUF string `xml:"lineNetAmountHUF"` } -// VatRate contains the VAT rate and amount of a line. +// LineVatData contains the VAT amount in the invoice currency and HUF type LineVatData struct { LineVatAmount string `xml:"lineVatAmount"` LineVatAmountHUF string `xml:"lineVatAmountHUF"` diff --git a/internal/doc/summary.go b/internal/doc/summary.go index bd445e9..a2455f2 100644 --- a/internal/doc/summary.go +++ b/internal/doc/summary.go @@ -6,13 +6,15 @@ import ( "github.com/invopop/gobl/tax" ) -// Depends wether the invoice is simplified or not +// InvoiceSummary is the summary of the invoice +// It contains the totals of the invoice type InvoiceSummary struct { SummaryNormal *SummaryNormal `xml:"summaryNormal,omitempty"` //SummarySimplified *SummarySimplified `xml:"summarySimplified,omitempty"` //SummaryGrossData *SummaryGrossData `xml:"summaryGrossData,omitempty"` } +// SummaryNormal is the summary of a normal invoice type SummaryNormal struct { SummaryByVatRate []*SummaryByVatRate `xml:"summaryByVatRate"` InvoiceNetAmount string `xml:"invoiceNetAmount"` @@ -21,6 +23,7 @@ type SummaryNormal struct { InvoiceVatAmountHUF string `xml:"invoiceVatAmountHUF"` } +// SummaryByVatRate is the summary of a vat rate type SummaryByVatRate struct { VatRate *VatRate `xml:"vatRate"` VatRateNetData *VatRateNetData `xml:"vatRateNetData"` @@ -28,11 +31,13 @@ type SummaryByVatRate struct { //VatRateGrossData VatRateGrossData `xml:"vatRateGrossData, omitempty"` } +// VatRateNetData is the net data of a vat rate type VatRateNetData struct { VatRateNetAmount string `xml:"vatRateNetAmount"` VatRateNetAmountHUF string `xml:"vatRateNetAmountHUF"` } +// VatRateVatData is the vat data of a vat rate type VatRateVatData struct { VatRateVatAmount string `xml:"vatRateVatAmount"` VatRateVatAmountHUF string `xml:"vatRateVatAmountHUF"` diff --git a/internal/doc/supplier.go b/internal/doc/supplier.go index bb93a20..70502bf 100644 --- a/internal/doc/supplier.go +++ b/internal/doc/supplier.go @@ -16,23 +16,21 @@ type SupplierInfo struct { //ExciseLicenceNum string `xml:"exciseLicenceNum,omitempty"` // Number of supplier’s tax warehouse license or excise license (Act LXVIII of 2016) } -func newSupplierInfo(supplier *org.Party) (*SupplierInfo, error) { - taxNumber, groupNumber, err := newTaxNumber(supplier) - if err != nil { - return nil, err - } +func newSupplierInfo(supplier *org.Party) *SupplierInfo { + taxNumber, groupNumber := newTaxNumber(supplier) + if groupNumber != nil { return &SupplierInfo{ SupplierTaxNumber: taxNumber, GroupMemberTaxNumber: groupNumber, SupplierName: supplier.Name, SupplierAddress: newAddress(supplier.Addresses[0]), - }, nil + } } return &SupplierInfo{ SupplierTaxNumber: taxNumber, SupplierName: supplier.Name, SupplierAddress: newAddress(supplier.Addresses[0]), - }, nil + } } diff --git a/internal/doc/supplier_test.go b/internal/doc/supplier_test.go index c652b64..df026c5 100644 --- a/internal/doc/supplier_test.go +++ b/internal/doc/supplier_test.go @@ -7,7 +7,6 @@ import ( "github.com/invopop/gobl/org" "github.com/invopop/gobl/tax" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestNewSupplierInfo(t *testing.T) { @@ -30,8 +29,7 @@ func TestNewSupplierInfo(t *testing.T) { taxNumber := &TaxNumber{TaxPayerID: "98109858"} groupNumber := (*TaxNumber)(nil) - supplierInfo, err := newSupplierInfo(supplier) - require.NoError(t, err) + supplierInfo := newSupplierInfo(supplier) assert.NotNil(t, supplierInfo) assert.Equal(t, taxNumber, supplierInfo.SupplierTaxNumber) assert.Nil(t, supplierInfo.GroupMemberTaxNumber) @@ -61,9 +59,8 @@ func TestNewSupplierInfo(t *testing.T) { taxNumber = &TaxNumber{TaxPayerID: "88212131", VatCode: "5", CountyCode: "03"} groupNumber = &TaxNumber{TaxPayerID: "21114445", VatCode: "4", CountyCode: "23"} - supplierInfo, err = newSupplierInfo(supplier) + supplierInfo = newSupplierInfo(supplier) - require.NoError(t, err) assert.NotNil(t, supplierInfo) assert.Equal(t, taxNumber, supplierInfo.SupplierTaxNumber) assert.Equal(t, groupNumber, supplierInfo.GroupMemberTaxNumber) diff --git a/internal/doc/taxnumber.go b/internal/doc/taxnumber.go index 7767356..eee9cea 100644 --- a/internal/doc/taxnumber.go +++ b/internal/doc/taxnumber.go @@ -12,7 +12,7 @@ type TaxNumber struct { CountyCode string `xml:"base:countyCode,omitempty"` } -func newTaxNumber(party *org.Party) (*TaxNumber, *TaxNumber, error) { +func newTaxNumber(party *org.Party) (*TaxNumber, *TaxNumber) { taxID := party.TaxID if taxID.Country == l10n.HU.Tax() { if len(taxID.Code) == 11 { @@ -20,19 +20,19 @@ func newTaxNumber(party *org.Party) (*TaxNumber, *TaxNumber, error) { if taxID.Code.String()[8:9] == "5" { groupMemberCode := party.Identities[0].Code.String() return newHungarianTaxNumber(taxID.Code.String()), - newHungarianTaxNumber(groupMemberCode), nil + newHungarianTaxNumber(groupMemberCode) } - return newHungarianTaxNumber(taxID.Code.String()), nil, nil + return newHungarianTaxNumber(taxID.Code.String()), nil } // If the tax number is not 11 characters long, then it is 8 characters long return &TaxNumber{ TaxPayerID: taxID.Code.String(), - }, nil, nil + }, nil } // If it is not a Hungarian tax number, then return the tax number with the country code return &TaxNumber{ TaxPayerID: taxID.String(), - }, nil, nil + }, nil } diff --git a/internal/doc/taxnumber_test.go b/internal/doc/taxnumber_test.go index 6f48d00..c21f3eb 100644 --- a/internal/doc/taxnumber_test.go +++ b/internal/doc/taxnumber_test.go @@ -88,11 +88,8 @@ func TestNewTaxNumber(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mainTaxNum, groupTaxNum, err := newTaxNumber(tt.party) + mainTaxNum, groupTaxNum := newTaxNumber(tt.party) - if err != tt.expectedErr { - t.Errorf("expected error %v, got %v", tt.expectedErr, err) - } if !reflect.DeepEqual(mainTaxNum, tt.expectedMain) { t.Errorf("expected mainTaxNum %v, got %v", tt.expectedMain, mainTaxNum) } diff --git a/internal/gateways/common.go b/internal/gateways/common.go index 397679f..b394d3d 100644 --- a/internal/gateways/common.go +++ b/internal/gateways/common.go @@ -15,14 +15,14 @@ import ( const ( charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - RequestVersion = "3.0" - HeaderVersion = "1.0" + requestVersion = "3.0" + headerVersion = "1.0" ) // Header is the common header for all requests // A new RequestId and Timestamp is generated for each request type Header struct { - RequestId string `xml:"common:requestId"` + RequestID string `xml:"common:requestId"` Timestamp string `xml:"common:timestamp"` RequestVersion string `xml:"common:requestVersion"` HeaderVersion string `xml:"common:headerVersion"` @@ -51,7 +51,7 @@ type RequestSignature struct { // Software is the information about the software used for issuing the invoices type Software struct { - SoftwareId string `xml:"softwareId"` + SoftwareID string `xml:"softwareId"` SoftwareName string `xml:"softwareName"` SoftwareOperation string `xml:"softwareOperation"` SoftwareMainVersion string `xml:"softwareMainVersion"` @@ -92,10 +92,10 @@ type Notification struct { // NewHeader creates a new Header with the given requestID and timestamp func NewHeader(requestID string, timestamp time.Time) *Header { return &Header{ - RequestId: requestID, + RequestID: requestID, Timestamp: timestamp.Format("2006-01-02T15:04:05.00Z"), - RequestVersion: RequestVersion, - HeaderVersion: HeaderVersion, + RequestVersion: requestVersion, + HeaderVersion: headerVersion, } } @@ -150,7 +150,7 @@ func NewSoftware(taxNumber tax.Identity, name string, operation string, version } return &Software{ - SoftwareId: NewSoftwareID(taxNumber), + SoftwareID: newSoftwareID(taxNumber), SoftwareName: name, SoftwareOperation: operation, SoftwareMainVersion: version, @@ -161,7 +161,7 @@ func NewSoftware(taxNumber tax.Identity, name string, operation string, version } } -func NewSoftwareID(taxNumber tax.Identity) string { +func newSoftwareID(taxNumber tax.Identity) string { // 18-length string: //first characters are the country code and tax id //the rest is random @@ -171,7 +171,7 @@ func NewSoftwareID(taxNumber tax.Identity) string { return taxNumber.String() + generateRandomString(lenRandom) } -func NewRequestID(timestamp time.Time) string { +func newRequestID(timestamp time.Time) string { timeUnique := timestamp.Format("20060102150405") randomNumber := rand.Intn(17) diff --git a/internal/gateways/gateways.go b/internal/gateways/gateways.go index 71763dc..b87fdaa 100644 --- a/internal/gateways/gateways.go +++ b/internal/gateways/gateways.go @@ -1,9 +1,11 @@ +// Package gateways provides all the endpoint connections to the NAV API package gateways import ( "github.com/go-resty/resty/v2" ) +// Client is the client to the NAV API type Client struct { user *User software *Software @@ -12,6 +14,7 @@ type Client struct { rest *resty.Client } +// User contains all the user information needed to authenticate to the NAV API type User struct { login string password string @@ -38,7 +41,7 @@ const ( StatusEndpoint = "invoiceService/v3/queryTransactionStatus" ) -// NewGateways creates a new gateways instance +// New creates a new gateways instance func New(user *User, software *Software, environment Environment) *Client { c := &Client{ user: user, diff --git a/internal/gateways/invoice.go b/internal/gateways/invoice.go index 8af1197..490d221 100644 --- a/internal/gateways/invoice.go +++ b/internal/gateways/invoice.go @@ -39,14 +39,16 @@ type ElectronicInvoiceHash struct { Value string `xml:",chardata"` } +// ManageInvoiceResponse contains all the information received after reporting an invoice type ManageInvoiceResponse struct { XMLName xml.Name `xml:"ManageInvoiceResponse"` Header *Header `xml:"header"` Result *Result `xml:"result"` Software *Software `xml:"software"` - TransactionId string `xml:"transactionId"` + TransactionID string `xml:"transactionId"` } +// ReportInvoice reports an invoice to the NAV API func (g *Client) ReportInvoice(invoice []byte, operationType string) (string, error) { // We first fetch the exchange token err := g.GetToken() @@ -60,7 +62,7 @@ func (g *Client) ReportInvoice(invoice []byte, operationType string) (string, er func (g *Client) newManageInvoiceRequest(invoice string, operationType string) ManageInvoiceRequest { timestamp := time.Now().UTC() - requestID := NewRequestID(timestamp) + requestID := newRequestID(timestamp) return ManageInvoiceRequest{ Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", @@ -98,7 +100,7 @@ func (g *Client) postManageInvoiceRequest(requestData ManageInvoiceRequest) (str if err != nil { return "", err } - return manageInvoiceResponse.TransactionId, nil + return manageInvoiceResponse.TransactionID, nil } var generalErrorResponse GeneralErrorResponse diff --git a/internal/gateways/status.go b/internal/gateways/status.go index ea8be0c..9330e87 100644 --- a/internal/gateways/status.go +++ b/internal/gateways/status.go @@ -6,6 +6,7 @@ import ( "time" ) +// QueryTransactionStatusRequest contains the needed data to query the status of a transaction type QueryTransactionStatusRequest struct { XMLName xml.Name `xml:"QueryTransactionStatusRequest"` Common string `xml:"xmlns:common,attr"` @@ -13,10 +14,11 @@ type QueryTransactionStatusRequest struct { Header *Header `xml:"common:header"` User *UserRequest `xml:"common:user"` Software *Software `xml:"software"` - TransactionId string `xml:"transactionId"` + TransactionID string `xml:"transactionId"` ReturnOriginalRequest bool `xml:"returnOriginalRequest,omitempty"` } +// QueryTransactionStatusResponse contains the response from the NAV API after querying the status of a transaction type QueryTransactionStatusResponse struct { XMLName xml.Name `xml:"QueryTransactionStatusResponse"` Header *Header `xml:"header"` @@ -25,12 +27,16 @@ type QueryTransactionStatusResponse struct { ProcessingResults *ProcessingResults `xml:"processingResults"` } +// ProcessingResults contains the results of a transaction +// It contains a list of ProcessingResult, which contains the status of each invoice in the transaction type ProcessingResults struct { ProcessingResult []*ProcessingResult `xml:"processingResult"` OriginalRequestVersion string `xml:"originalRequestVersion"` //AnnulmentData *AnnulmentData `xml:"annulmentData,omitempty"` } +// ProcessingResult contains the status of an invoice in a transaction +// It also contains the messages from the technical and business validations type ProcessingResult struct { Index string `xml:"index"` BatchIndex string `xml:"batchIndex,omitempty"` @@ -41,12 +47,14 @@ type ProcessingResult struct { OriginalRequest string `xml:"originalRequest,omitempty"` } +// TechnicalValidationMessages are the result of the technical validation type TechnicalValidationMessages struct { ValidationResultCode string `xml:"validationResultCode"` ValidationErrorCode string `xml:"validationErrorCode,omitempty"` Message string `xml:"message,omitempty"` } +// BusinessValidationMessages are the result of the business validation type BusinessValidationMessages struct { ValidationResultCode string `xml:"validationResultCode"` ValidationErrorCode string `xml:"validationErrorCode,omitempty"` @@ -54,6 +62,7 @@ type BusinessValidationMessages struct { Pointer *Pointer `xml:"pointer,omitempty"` } +// Pointer points to the specific part of the invoice that caused the validation to fail type Pointer struct { Tag string `xml:"tag,omitempty"` Value string `xml:"value,omitempty"` @@ -61,6 +70,7 @@ type Pointer struct { OriginalInvoiceNumber string `xml:"originalInvoiceNumber,omitempty"` } +// GetStatus queries the status of a transaction func (g *Client) GetStatus(transactionID string) ([]*ProcessingResult, error) { requestData := g.newQueryTransactionStatusRequest(transactionID) return g.queryTransactionStatus(requestData) @@ -68,14 +78,14 @@ func (g *Client) GetStatus(transactionID string) ([]*ProcessingResult, error) { func (g *Client) newQueryTransactionStatusRequest(transactionID string) QueryTransactionStatusRequest { timestamp := time.Now().UTC() - requestID := NewRequestID(timestamp) + requestID := newRequestID(timestamp) return QueryTransactionStatusRequest{ Xmlns: "http://schemas.nav.gov.hu/OSA/3.0/api", Common: "http://schemas.nav.gov.hu/NTCA/1.0/common", Header: NewHeader(requestID, timestamp), User: g.NewUser(requestID, timestamp), Software: g.software, - TransactionId: transactionID, + TransactionID: transactionID, } } diff --git a/internal/gateways/token.go b/internal/gateways/token.go index 25bfa41..e28be82 100644 --- a/internal/gateways/token.go +++ b/internal/gateways/token.go @@ -8,11 +8,13 @@ import ( "time" ) +// TokenInfo stores the token and its expiration time type TokenInfo struct { Token string Expiration time.Time } +// TokenExchangeRequest is the request for the token exchange type TokenExchangeRequest struct { XMLName xml.Name `xml:"TokenExchangeRequest"` Common string `xml:"xmlns:common,attr"` @@ -22,6 +24,7 @@ type TokenExchangeRequest struct { Software *Software `xml:"software"` } +// TokenExchangeResponse contains some header information, the token and the expiration time type TokenExchangeResponse struct { XMLName xml.Name `xml:"TokenExchangeResponse"` Header *Header `xml:"header"` @@ -32,6 +35,7 @@ type TokenExchangeResponse struct { TokenValidityTo string `xml:"tokenValidityTo"` } +// GetToken gets the token from the NAV API func (g *Client) GetToken() error { requestData := g.newTokenExchangeRequest() @@ -98,7 +102,7 @@ func (g *Client) postTokenExchangeRequest(requestData TokenExchangeRequest) (*To func (g *Client) newTokenExchangeRequest() TokenExchangeRequest { timestamp := time.Now().UTC() - requestID := NewRequestID(timestamp) //This must be unique for each request + requestID := newRequestID(timestamp) //This must be unique for each request return TokenExchangeRequest{ Xmlns: APIXMNLS, Common: APICommon, @@ -108,15 +112,16 @@ func (g *Client) newTokenExchangeRequest() TokenExchangeRequest { } } +// Expired checks if the token is expired func (tok *TokenInfo) Expired() bool { return time.Now().After(tok.Expiration) } -func (tokenInfo *TokenInfo) decrypt(keyString string) error { +func (tok *TokenInfo) decrypt(keyString string) error { key := []byte(keyString) // Decode the base64 encoded encrypted key - ciphertext, err := base64.StdEncoding.DecodeString(tokenInfo.Token) + ciphertext, err := base64.StdEncoding.DecodeString(tok.Token) if err != nil { return err } @@ -138,7 +143,7 @@ func (tokenInfo *TokenInfo) decrypt(keyString string) error { // Remove padding (if any) decrypted = unpad(decrypted) - tokenInfo.Token = string(decrypted) + tok.Token = string(decrypted) return nil } diff --git a/nav_test.go b/nav_test.go index 6bb08e3..42e9551 100644 --- a/nav_test.go +++ b/nav_test.go @@ -40,12 +40,12 @@ func TestReportInvoice(t *testing.T) { navClient := NewNav(user, software, InTesting()) - invoice, err := os.ReadFile("test/data/out/credit-note.xml") + invoice, err := os.ReadFile("test/data/out/invoice-standard.xml") if err != nil { t.Fatalf("Failed to read sample invoice file: %v", err) } - transactionID, err := navClient.ReportInvoice(invoice, "MODIFY") + transactionID, err := navClient.ReportInvoice(invoice, "CREATE") fmt.Println("Transaction ID: ", transactionID) From b775874216d20d7c21af8d4435712022788e68c4 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Tue, 24 Sep 2024 09:57:08 +0000 Subject: [PATCH 23/27] Correcting mistake Readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index efe6344..840c38c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ func main() { panic(err) } - // Prepare the CFDI document + // Prepare the Nav document doc, err := nav.NewDocument(env) if err != nil { panic(err) From dd738819b4ae438f6e4df1dc7078df11aa24522b Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Tue, 24 Sep 2024 11:14:43 +0000 Subject: [PATCH 24/27] Changing status handling --- README.md | 60 +++++++++++++++++++++++++------- internal/gateways/status.go | 14 ++++---- internal/gateways/status_test.go | 3 +- 3 files changed, 56 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 840c38c..b4b7741 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ import ( func main() { // Software is the information regarding the system used to report the invoices - software := NewSoftware( + software := nav.NewSoftware( tax.Identity{Country: l10n.ES.Tax(), Code: cbc.Code("B12345678")}, "Invopop", "ONLINE_SERVICE", @@ -71,43 +71,77 @@ func main() { ) // User is all the data obtained from the technical user that it is needed to report the invoices - user := NewUser( + user := nav.NewUser( "username", "password", "signature_key", "exchange_key", - "taxID" + "taxID", ) // Create a new client with the user and software data and choose if you want to issue the invoices in the testing or production environment - navClient := NewNav(user, software, InTesting()) + navClient := nav.NewNav(user, software, nav.InTesting()) //We load the invoice - inv, err := os.ReadFile("test/data/out/output.xml") + invoice, err := os.ReadFile("test/data/out/output.xml") if err != nil { panic(err) } // Report the invoice - transactionId, err := navClient.ReportInvoice(invoice) + transactionId, err := navClient.ReportInvoice(invoice, "CREATE") if err != nil { panic(err) } - // Once the invoice is reported, you can check the status - // If you check the status too early you would get a status of PROCESSING, which means that you should try again later to query the status - resultsList, err := navClient.GetTransactionStatus(transactionId) + // Keep the transaction ID for the status query +} +``` + +#### Invoice Status +Once an invoice is reported, you can query the status of the invoice at any time. + +```go +package main + +import ( + "os" + + "github.com/invopop/gobl" + nav "github.com/invopop/gobl.hu-nav" +) + +func main(){ + + // To query the status of an invoice, you need the transaction ID, which is returned by the ReportInvoice function. + transactionId := "4Q220PNVP43MOU5G" + + // Create a new client with the user and software data and choose if you want to issue the invoices in the testing or production environment + navClient := nav.NewNav(user, software, nav.InTesting()) - //The output contains the status and a list of technical and business validation messages. To visualize the output, you can create a XML output: - out, err := nav.BytesIndent(resultsList) + // Query the status of the invoice + resultsList, err := navClient.GetTransactionStatus(transactionId) if err != nil { panic(err) } - // TODO: do something with the output + // resultsList is a list of ProcessingResult, which contains the status of each invoice in the transaction + // You can access the status of each invoice by iterating through the list + for _, r := range resultsList { + fmt.Println(r.InvoiceStatus) + } + + // If you want to see the detailed messages, you can access the TechnicalValidationMessages and BusinessValidationMessages fields, that are also lists + for _, r := range resultsList { + for _, m := range r.TechnicalValidationMessages { + fmt.Println(m.Message) + } + for _, m := range r.BusinessValidationMessages { + fmt.Println(m.Message) + } + } } ``` - ### Command Line #### Conversion diff --git a/internal/gateways/status.go b/internal/gateways/status.go index 9330e87..d5eb7e8 100644 --- a/internal/gateways/status.go +++ b/internal/gateways/status.go @@ -38,13 +38,13 @@ type ProcessingResults struct { // ProcessingResult contains the status of an invoice in a transaction // It also contains the messages from the technical and business validations type ProcessingResult struct { - Index string `xml:"index"` - BatchIndex string `xml:"batchIndex,omitempty"` - InvoiceStatus string `xml:"invoiceStatus"` - TechnicalValidationMessages *TechnicalValidationMessages `xml:"technicalValidationMessages,omitempty"` - BusinessValidationMessages *BusinessValidationMessages `xml:"businessValidationMessages,omitempty"` - CompressedContentIndicator bool `xml:"compressedContentIndicator"` - OriginalRequest string `xml:"originalRequest,omitempty"` + Index string `xml:"index"` + BatchIndex string `xml:"batchIndex,omitempty"` + InvoiceStatus string `xml:"invoiceStatus"` + TechnicalValidationMessages []*TechnicalValidationMessages `xml:"technicalValidationMessages,omitempty"` + BusinessValidationMessages []*BusinessValidationMessages `xml:"businessValidationMessages,omitempty"` + CompressedContentIndicator bool `xml:"compressedContentIndicator"` + OriginalRequest string `xml:"originalRequest,omitempty"` } // TechnicalValidationMessages are the result of the technical validation diff --git a/internal/gateways/status_test.go b/internal/gateways/status_test.go index d90f43a..3536f59 100644 --- a/internal/gateways/status_test.go +++ b/internal/gateways/status_test.go @@ -39,7 +39,7 @@ func TestQueryTransactionStatus(t *testing.T) { client := New(user, software, Environment("testing")) - result, err := client.GetStatus("4P2PEVFLLNKYTV3I") + result, err := client.GetStatus("4Q220PNVP43MOU5G") // Assert the results assert.NoError(t, err) @@ -53,6 +53,7 @@ func TestQueryTransactionStatus(t *testing.T) { } fmt.Println(string(xmlData)) + fmt.Println(result[0].BusinessValidationMessages[1].Message) for _, r := range result { assert.Equal(t, "1", r.Index) From c087afa084c86efea398f2297f8eae94919ee854 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Tue, 24 Sep 2024 13:47:29 +0000 Subject: [PATCH 25/27] Changing client nav --- nav.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/nav.go b/nav.go index 05fd61a..8ab187c 100644 --- a/nav.go +++ b/nav.go @@ -12,20 +12,20 @@ import ( "github.com/invopop/gobl/tax" ) -// Nav is the main struct for interacting with the NAV API -type Nav struct { +// Client is the main struct for interacting with the NAV API +type Client struct { gw *gateways.Client env gateways.Environment } // Option is a function used for the different options of the Nav client // For the moment, the only option is the environment (production or testing) -type Option func(*Nav) +type Option func(*Client) // NewNav creates a new Nav client -func NewNav(user *gateways.User, software *gateways.Software, opts ...Option) *Nav { +func NewNav(user *gateways.User, software *gateways.Software, opts ...Option) *Client { - c := new(Nav) + c := new(Client) for _, opt := range opts { opt(c) @@ -38,31 +38,31 @@ func NewNav(user *gateways.User, software *gateways.Software, opts ...Option) *N // InProduction defines the connection to use the production environment. func InProduction() Option { - return func(c *Nav) { + return func(c *Client) { c.env = gateways.EnvironmentProduction } } // InTesting defines the connection to use the testing environment. func InTesting() Option { - return func(c *Nav) { + return func(c *Client) { c.env = gateways.EnvironmentTesting } } // FetchToken fetches the token from the NAV API -func (n *Nav) FetchToken() error { - return n.gw.GetToken() +func (c *Client) FetchToken() error { + return c.gw.GetToken() } // ReportInvoice reports an invoice to the NAV API -func (n *Nav) ReportInvoice(invoice []byte, operationType string) (string, error) { - return n.gw.ReportInvoice(invoice, operationType) +func (c *Client) ReportInvoice(invoice []byte, operationType string) (string, error) { + return c.gw.ReportInvoice(invoice, operationType) } // GetTransactionStatus gets the status of an invoice reporting transaction -func (n *Nav) GetTransactionStatus(transactionID string) ([]*gateways.ProcessingResult, error) { - return n.gw.GetStatus(transactionID) +func (c *Client) GetTransactionStatus(transactionID string) ([]*gateways.ProcessingResult, error) { + return c.gw.GetStatus(transactionID) } // NewSoftware creates a new Software with the information about the software developer From 1a76d640a81885cf7cfe3a405de2731de5c9ef83 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Tue, 24 Sep 2024 15:29:40 +0000 Subject: [PATCH 26/27] Adding invoice error message --- internal/gateways/invoice.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gateways/invoice.go b/internal/gateways/invoice.go index 490d221..cc75b55 100644 --- a/internal/gateways/invoice.go +++ b/internal/gateways/invoice.go @@ -109,5 +109,5 @@ func (g *Client) postManageInvoiceRequest(requestData ManageInvoiceRequest) (str return "", err } - return "", fmt.Errorf("error code: %s, message: %s", resp.Status(), generalErrorResponse.Result.ErrorCode) + return "", fmt.Errorf("error code: %s, message: %s", generalErrorResponse.Result.ErrorCode, generalErrorResponse.Result.Message) } From b27e28a6dd4c0ddf3a4da281c513cc1ce9bdf5b4 Mon Sep 17 00:00:00 2001 From: Menendez6 Date: Tue, 24 Sep 2024 16:57:02 +0000 Subject: [PATCH 27/27] updating go mod to resolve dependency issues --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 96d11c5..0ba6c97 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/go-resty/resty/v2 v2.14.0 github.com/invopop/gobl v0.113.0 github.com/joho/godotenv v1.5.1 - github.com/lestrrat-go/libxml2 v0.0.0-20240521004304-a75c203ac627 + github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.25.0 diff --git a/go.sum b/go.sum index a4e5bb4..384529f 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/lestrrat-go/libxml2 v0.0.0-20240521004304-a75c203ac627 h1:M7EEuhGDmn9bWSnap5mzVlg8pmVucmLytCjVjm+H4QU= -github.com/lestrrat-go/libxml2 v0.0.0-20240521004304-a75c203ac627/go.mod h1:/0MMipmS+5SMXCSkulsvJwYmddKI4IL5tVy6AZMo9n0= +github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3 h1:ZIYZ0+TEddrxA2dEx4ITTBCdRqRP8Zh+8nb4tSx0nOw= +github.com/lestrrat-go/libxml2 v0.0.0-20240905100032-c934e3fcb9d3/go.mod h1:/0MMipmS+5SMXCSkulsvJwYmddKI4IL5tVy6AZMo9n0= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=