From b2b4e3f30b4ea39bddd40335d6be91a8f2959a29 Mon Sep 17 00:00:00 2001 From: Grzegorz Lisowski Date: Fri, 2 Feb 2024 10:27:38 +0100 Subject: [PATCH 1/4] Authorize KSeF API session We want to be able to post invoices into the KSeF API and get back the UPO that proves that the invoice has been accepted by the system. The first step is communicating with the API to get the interactive session. To get the interactive session we need to have the context in which we send the requests this context is the tax id of the company. Next we need a valid token from ksef it can be generated from ksef.mf.gov.pl/web for production or from ksef-test.mf.gov.pl/web for test. The last thing is the public key provided from https://ksef.mf.gov.pl/ and https://ksef-test.mf.gov.pl/. To get the session token we first need to get the authorization challenge for our context. Then we use the context, token and authorization challenge to create a interactive session and get the session token used in other requests. --- api/authorization_challenge.go | 49 ++++++++++++ api/client.go | 142 +++++++++++++++++++++++++++++++++ api/client_test.go | 19 +++++ api/error_response.go | 16 ++++ api/init_token_session.go | 136 +++++++++++++++++++++++++++++++ api/keys/demo.pem | 3 + api/keys/prod.pem | 3 + api/keys/test.pem | 3 + api/test/test.go | 45 +++++++++++ go.mod | 3 + go.sum | 44 ++++++++++ 11 files changed, 463 insertions(+) create mode 100644 api/authorization_challenge.go create mode 100644 api/client.go create mode 100644 api/client_test.go create mode 100644 api/error_response.go create mode 100644 api/init_token_session.go create mode 100644 api/keys/demo.pem create mode 100644 api/keys/prod.pem create mode 100644 api/keys/test.pem create mode 100644 api/test/test.go diff --git a/api/authorization_challenge.go b/api/authorization_challenge.go new file mode 100644 index 0000000..d1602a4 --- /dev/null +++ b/api/authorization_challenge.go @@ -0,0 +1,49 @@ +package api + +import ( + "context" + "errors" + "time" +) + +// AuthorisationChallengeResponse defines the authorization challenge response +type AuthorisationChallengeResponse struct { + Timestamp time.Time `json:"timestamp"` + Challenge string `json:"challenge"` +} + +// AuthorisationChallengeRequest defines the structure of the token session initialization +type AuthorisationChallengeRequest struct { + ContextIdentifier *ContextIdentifier `json:"contextIdentifier"` +} + +// ContextIdentifier defines the ContextIdentifier part of the authorization challenge +type ContextIdentifier struct { + Identifier string `json:"identifier"` + Type string `json:"type"` +} + +func fetchChallenge(ctx context.Context, c *Client) (*AuthorisationChallengeResponse, error) { + response := &AuthorisationChallengeResponse{} + var errorResponse ErrorResponse + + request := &AuthorisationChallengeRequest{ + ContextIdentifier: &ContextIdentifier{ + Identifier: c.ID, + Type: "onip", + }, + } + + resp, err := c.Client.R(). + SetResult(response). + SetBody(request). + SetContext(ctx). + Post(c.URL + "/api/online/Session/AuthorisationChallenge") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, errors.New(errorResponse.Exception.ExceptionDetailList[0].ExceptionDescription) + } + return response, nil +} diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..1f0f678 --- /dev/null +++ b/api/client.go @@ -0,0 +1,142 @@ +// Package api used for communication with the KSeF API +package api + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + + "github.com/go-resty/resty/v2" +) + +// ClientOptFunc defines function for customizing the KSeF client +type ClientOptFunc func(*ClientOpts) + +// ClientOpts defines the client parameters +type ClientOpts struct { + Client *resty.Client + URL string + ID string + Token string + SessionToken string + SessionReference string + KeyPath string +} + +func defaultClientOpts() ClientOpts { + return ClientOpts{ + Client: resty.New(), + URL: "https://ksef-test.mf.gov.pl", + ID: "", + Token: "", + SessionToken: "", + SessionReference: "", + KeyPath: "", + } +} + +// Client defines KSeF client +type Client struct { + ClientOpts +} + +// WithClient allows to customize the http client used for making the requests +func WithClient(client *resty.Client) ClientOptFunc { + return func(o *ClientOpts) { + o.Client = client + } +} + +// WithID allows customizing the Polish tax id number (NIP) +func WithID(id string) ClientOptFunc { + return func(o *ClientOpts) { + o.ID = id + } +} + +// WithToken allows customizing the KSeF authorization token +func WithToken(token string) ClientOptFunc { + return func(o *ClientOpts) { + o.Token = token + } +} + +// WithKeyPath allows customizing the public key for KSeF API authorization +func WithKeyPath(keyPath string) ClientOptFunc { + return func(o *ClientOpts) { + o.KeyPath = keyPath + } +} + +// WithProductionURL sets the client url to KSeF production +func WithProductionURL(o *ClientOpts) { + o.URL = "https://ksef.mf.gov.pl" +} + +// WithDemoURL sets the client url to KSeF demo +func WithDemoURL(o *ClientOpts) { + o.URL = "https://ksef-demo.mf.gov.pl" +} + +// NewClient returns a KSeF API client +func NewClient(opts ...ClientOptFunc) *Client { + o := defaultClientOpts() + for _, fn := range opts { + fn(&o) + } + o.Client.SetDebug(true) + return &Client{ + ClientOpts: o, + } +} + +// FetchSessionToken requests new session token +func FetchSessionToken(ctx context.Context, c *Client) error { + challenge, err := fetchChallenge(ctx, c) + if err != nil { + return err + } + + encryptedToken, err := encryptToken(c, challenge) + if err != nil { + return fmt.Errorf("cannot encrypt token: %v", err) + } + + sessionToken, err := initTokenSession(ctx, c, encryptedToken, challenge.Challenge) + if err != nil { + return fmt.Errorf("cannot init session token: %v", err) + } + + c.SessionToken = sessionToken.SessionToken.Token + c.SessionReference = sessionToken.ReferenceNumber + + return nil +} + +func publicKey(keyPath string) (*rsa.PublicKey, error) { + key, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("cannot read key file %s: %v", keyPath, err) + } + block, _ := pem.Decode(key) + parsedKey, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("cannot parse public key: %v", err) + } + return parsedKey.(*rsa.PublicKey), nil +} + +func encryptToken(c *Client, challenge *AuthorisationChallengeResponse) ([]byte, error) { + rawToken := fmt.Sprintf("%s|%d", c.Token, challenge.Timestamp.UnixMilli()) + + publicKey, err := publicKey(c.KeyPath) + if err != nil { + return nil, err + } + + return rsa.EncryptPKCS1v15(rand.Reader, publicKey, []byte(rawToken)) +} diff --git a/api/client_test.go b/api/client_test.go new file mode 100644 index 0000000..6cba2a9 --- /dev/null +++ b/api/client_test.go @@ -0,0 +1,19 @@ +package api_test + +import ( + "testing" + + api_test "github.com/invopop/gobl.ksef/api/test" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestFetchSessionToken(t *testing.T) { + t.Run("should get session token", func(t *testing.T) { + client, err := api_test.Client() + defer httpmock.DeactivateAndReset() + assert.NoError(t, err) + + assert.Equal(t, client.SessionToken, "exampleSessionToken") + }) +} diff --git a/api/error_response.go b/api/error_response.go new file mode 100644 index 0000000..f1469fa --- /dev/null +++ b/api/error_response.go @@ -0,0 +1,16 @@ +package api + +// ErrorResponse parses error responses +type ErrorResponse struct { + Exception struct { + ServiceCtx string `json:"serviceCtx"` + ServiceCode string `json:"serviceCode"` + ServiceName string `json:"serviceName"` + Timestamp string `json:"timestamp"` + ReferenceNumber string `json:"referenceNumber"` + ExceptionDetailList []struct { + ExceptionCode int `json:"exceptionCode"` + ExceptionDescription string `json:"exceptionDescription"` + } `json:"exceptionDetailList"` + } `json:"exception"` +} diff --git a/api/init_token_session.go b/api/init_token_session.go new file mode 100644 index 0000000..7bfdb29 --- /dev/null +++ b/api/init_token_session.go @@ -0,0 +1,136 @@ +package api + +import ( + "context" + "encoding/base64" + "encoding/xml" + "errors" +) + +// InitSessionTokenResponse defines the token session initialization response structure +type InitSessionTokenResponse struct { + Timestamp string `json:"timestamp"` + ReferenceNumber string `json:"referenceNumber"` + SessionToken *SessionToken `json:"sessionToken"` +} + +// SessionToken defines the session token part of the token session initialization response +type SessionToken struct { + Token string `json:"token"` + Context struct { + ContextIdentifier struct { + Type string `json:"type"` + Identifier string `json:"identifier"` + } `json:"contextIdentifier"` + ContextName struct { + Type string `json:"type"` + TradeName string `json:"tradeName"` + FullName string `json:"fullName"` + } `json:"contextName"` + CredentialsRoleList []struct { + Type string `json:"type"` + RoleType string `json:"roleType"` + RoleDescription string `json:"roleDescription"` + } `json:"credentialsRoleList"` + } `json:"context"` +} + +// InitSessionTokenRequest defines the structure of the token session initialization +type InitSessionTokenRequest struct { + Context *InitSessionTokenContext `xml:"ns3:Context"` + XMLName xml.Name + XMLNamespace string `xml:"xmlns,attr"` + XMLNamespace2 string `xml:"xmlns:ns2,attr"` + XMLNamespace3 string `xml:"xmlns:ns3,attr"` +} + +// InitSessionTokenContext defines the Context part of the token session initialization +type InitSessionTokenContext struct { + Challenge string `xml:"Challenge"` + Identifier *InitSessionTokenIdentifier `xml:"Identifier"` + DocumentType *InitSessionTokenDocumentType `xml:"DocumentType"` + Token string `xml:"Token"` +} + +// InitSessionTokenIdentifier defines the Identifier part of the token session initialization +type InitSessionTokenIdentifier struct { + Identifier string `xml:"ns2:Identifier"` + Type string `xml:"xsi:type,attr"` + Namespace string `xml:"xmlns:xsi,attr"` +} + +// InitSessionTokenDocumentType defines the DocumentType part of the token session initialization +type InitSessionTokenDocumentType struct { + Service string `xml:"ns2:Service"` + FormCode *InitSessionTokenFormCode `xml:"ns2:FormCode"` +} + +// InitSessionTokenFormCode defines the FormCode part of the token session initialization +type InitSessionTokenFormCode struct { + SystemCode string `xml:"ns2:SystemCode"` + SchemaVersion string `xml:"ns2:SchemaVersion"` + TargetNamespace string `xml:"ns2:TargetNamespace"` + Value string `xml:"ns2:Value"` +} + +const ( + // XMLNamespace namespace setting for token initialization XML + XMLNamespace = "http://ksef.mf.gov.pl/schema/gtw/svc/online/types/2021/10/01/0001" + // XMLNamespace2 namespace setting for token initialization XML + XMLNamespace2 = "http://ksef.mf.gov.pl/schema/gtw/svc/types/2021/10/01/0001" + // XMLNamespace3 namespace setting for token initialization XML + XMLNamespace3 = "http://ksef.mf.gov.pl/schema/gtw/svc/online/auth/request/2021/10/01/0001" + // XSIType namespace setting for token initialization XML + XSIType = "ns2:SubjectIdentifierByCompanyType" + // XSINamespace namespace setting for token initialization XML + XSINamespace = "http://www.w3.org/2001/XMLSchema-instance" + // RootElementName root element name for token initialization XML + RootElementName = "ns3:InitSessionTokenRequest" +) + +func initTokenSession(ctx context.Context, c *Client, token []byte, challenge string) (*InitSessionTokenResponse, error) { + response := &InitSessionTokenResponse{} + var errorResponse ErrorResponse + + request := &InitSessionTokenRequest{ + XMLNamespace: XMLNamespace, + XMLNamespace2: XMLNamespace2, + XMLNamespace3: XMLNamespace3, + XMLName: xml.Name{Local: RootElementName}, + Context: &InitSessionTokenContext{ + Identifier: &InitSessionTokenIdentifier{ + Namespace: XSINamespace, + Type: XSIType, + Identifier: c.ID, + }, + Challenge: challenge, + DocumentType: &InitSessionTokenDocumentType{ + Service: "KSeF", + FormCode: &InitSessionTokenFormCode{ + SystemCode: "FA (2)", + SchemaVersion: "1-0E", + TargetNamespace: "http://crd.gov.pl/wzor/2023/06/29/12648", + Value: "FA", + }, + }, + Token: base64.StdEncoding.EncodeToString(token), + }, + } + bytes, _ := bytes(*request) + + resp, err := c.Client.R(). + SetResult(response). + SetError(&errorResponse). + SetBody(bytes). + SetContext(ctx). + SetHeader("Content-Type", "application/octet-stream; charset=utf-8"). + Post(c.URL + "/api/online/Session/InitToken") + + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, errors.New(errorResponse.Exception.ExceptionDetailList[0].ExceptionDescription) + } + return response, nil +} diff --git a/api/keys/demo.pem b/api/keys/demo.pem new file mode 100644 index 0000000..0e949b2 --- /dev/null +++ b/api/keys/demo.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwocTwdNgt2+PXJ2fcB7k1kn5eFUTXBeep9pHLx6MlfkmHLvgjVpQy1/hqMTFfZqw6piFOdZMOSLgizRKjb1CtDYhWncg0mML+yhVrPyHT7bkbqfDuM2ku3q8ueEOy40SEl4jRMNvttkWnkvf/VTy2TwA9X9vTd61KJmDDZBLOCVqsyzdnELKUE8iulXwTarDvVTx4irnz/GY+y9qod+XrayYndtU6/kDgasAAQv0pu7esFFPMr83Nkqdu6JD5/0yJOl5RShQXwlmToqvpih2+L92x865/C4f3n+dZ9bgsKDGSkKSqq7Pz+QnhF7jV/JAmtJBCIMylxdxI/xfDHZ5XwIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/api/keys/prod.pem b/api/keys/prod.pem new file mode 100644 index 0000000..30d54a8 --- /dev/null +++ b/api/keys/prod.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtCVoNVHGeaOwmzuFMiScJozTbh+ULVtQYmRNTON+20ilBOqkHrJRUZCtXUg0w+ztYMvWFr4U74ykGMnEYODT7l2F8JGuJeE9YGK8hKqaY5h0YYxJW7fWybZOxQJhwXzuasjKt/OHYWrI6SmL96bSanr6MwGNr6yiNQV3R6EFB/wpZ4scwh8ZfEs0kk29uIgZVEbkq+9n/xRQjbAtaQs6eiDb4AUOBd7nm4+Uis5goHkjTtJwmhcpQq5Vw7lug3FUsn7/luNyCVhaR4BkpB3NVexxepYSByJneFrOgOh/3GilK2a47WPAEVG3hRQAiGBUR0m7Ev7WYboQtA1TI7hc6wIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/api/keys/test.pem b/api/keys/test.pem new file mode 100644 index 0000000..bf60ef6 --- /dev/null +++ b/api/keys/test.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuWosgHSpiRLadA0fQbzshi5TluliZfDsJujPlyYqp6A3qnzS3WmHxtwgO58uTbemQ1HCC2qwrMwuJqR6l8tgA4ilBMDbEEtkzgbjkJ6xoEqBptgxivP/ovOFYYoAnY6brZhXytCamSvjY9KI0g0McRk24pOueXT0cbb0tlwEEjVZ8NveQNKT2c1EEE2cjmW0XB3UlIBqNqiY2rWF86DcuFDTUy+KzSmTJTFvU/ENNyLTh5kkDOmB1SY1Zaw9/Q6+a4VJ0urKZPw+61jtzWmucp4CO2cfXg9qtF6cxFIrgfbtvLofGQg09Bh7Y6ZA5VfMRDVDYLjvHwDYUHg2dPIk0wIDAQAB +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/api/test/test.go b/api/test/test.go new file mode 100644 index 0000000..54c5ba5 --- /dev/null +++ b/api/test/test.go @@ -0,0 +1,45 @@ +// Package api_test provides tools for testing the api +package api_test + +import ( + "context" + "time" + + "github.com/go-resty/resty/v2" + ksef_api "github.com/invopop/gobl.ksef/api" + "github.com/jarcoal/httpmock" +) + +// Client creates authorized client for testing +func Client() (*ksef_api.Client, error) { + mockClient := resty.New() + + httpmock.ActivateNonDefault(mockClient.GetClient()) + + reqT, err := time.Parse("2006-01-02T15:04:05.000Z", "2024-01-26T16:18:51.701Z") + if err != nil { + return nil, err + } + + httpmock.RegisterResponder("POST", "https://ksef-test.mf.gov.pl/api/online/Session/AuthorisationChallenge", + httpmock.NewJsonResponderOrPanic(200, &ksef_api.AuthorisationChallengeResponse{Timestamp: reqT, Challenge: "20240126-CR-077CAFEC31-83ACAC25E4-64"})) + + sessionToken := "exampleSessionToken" + httpmock.RegisterResponder("POST", "https://ksef-test.mf.gov.pl/api/online/Session/InitToken", + httpmock.NewJsonResponderOrPanic(200, &ksef_api.InitSessionTokenResponse{ReferenceNumber: "ExampleReferenceNumber", SessionToken: &ksef_api.SessionToken{Token: sessionToken}})) + + client := ksef_api.NewClient( + ksef_api.WithClient(mockClient), + ksef_api.WithID("1234567788"), + ksef_api.WithToken("624A48824F01935DADE66C83D4874C0EF7AF0529CB5F0F412E6932F189D3864A"), + ksef_api.WithKeyPath("./keys/test.pem"), + ) + + ctx := context.Background() + err = ksef_api.FetchSessionToken(ctx, client) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/go.mod b/go.mod index 5451aa8..85b8d8e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/invopop/gobl.ksef go 1.20 require ( + github.com/go-resty/resty/v2 v2.11.0 github.com/invopop/gobl v0.65.1 + github.com/jarcoal/httpmock v1.3.1 github.com/joho/godotenv v1.5.1 github.com/spf13/cobra v1.7.0 github.com/terminalstatic/go-xsd-validate v0.1.5 @@ -18,6 +20,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + golang.org/x/net v0.17.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2262848..6090207 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t 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.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= +github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= 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/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= @@ -26,11 +28,14 @@ github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uO github.com/invopop/validation v0.3.0 h1:o260kbjXzoBO/ypXDSSrCLL7SxEFUXBsX09YTE9AxZw= github.com/invopop/validation v0.3.0/go.mod h1:qIBG6APYLp2Wu3/96p3idYjP8ffTKVmQBfKiZbw0Hts= github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= +github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= +github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= 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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g= 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= @@ -53,14 +58,53 @@ github.com/terminalstatic/go-xsd-validate v0.1.5 h1:RqpJnf6HGE2CB/lZB1A8BYguk8uR github.com/terminalstatic/go-xsd-validate v0.1.5/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= 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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +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/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.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +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/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.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 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/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +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/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= From 99b0ee2010f270562df05f922d77ae8f51cab39f Mon Sep 17 00:00:00 2001 From: Grzegorz Lisowski Date: Fri, 2 Feb 2024 13:58:46 +0100 Subject: [PATCH 2/4] Send invoice to the KSeF API We want to be able to post invoices into the KSeF API and get back the UPO that proves that the invoice has been accepted by the system. Here we prepare the request that posts the invoice to KSeF backend. We also prepare a request that checks the invoice status. With those requests we can post the invoice and check if it finished processing. --- api/invoice.go | 118 ++++++++++++++++++++++++++++++++ api/invoice_test.go | 53 ++++++++++++++ test/data/invoice-pl-pl.json | 2 +- test/data/out/invoice-pl-pl.xml | 2 +- test/data/out/output.xml | 77 --------------------- 5 files changed, 173 insertions(+), 79 deletions(-) create mode 100644 api/invoice.go create mode 100644 api/invoice_test.go delete mode 100755 test/data/out/output.xml diff --git a/api/invoice.go b/api/invoice.go new file mode 100644 index 0000000..e0ac6e6 --- /dev/null +++ b/api/invoice.go @@ -0,0 +1,118 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" +) + +// InvoiceStatusResponse defines the post invocie response structure +type InvoiceStatusResponse struct { + Timestamp string `json:"timestamp"` + ReferenceNumber string `json:"referenceNumber"` + ProcessingCode int `json:"processingCode"` + ProcessingDescription string `json:"processingDescription"` + ElementReferenceNumber string `json:"elementReferenceNumber"` + InvoiceStatus struct { + InvoiceNumber string `json:"invoiceNumber"` + KsefReferenceNumber string `json:"ksefReferenceNumber"` + AcquisitionTimestamp string `json:"acquisitionTimestamp"` + } `json:"invoiceStatus"` +} + +// SendInvoiceResponse defines the post invocie response structure +type SendInvoiceResponse struct { + Timestamp string `json:"timestamp"` + ReferenceNumber string `json:"referenceNumber"` + ProcessingCode int `json:"processingCode"` + ProcessingDescription string `json:"processingDescription"` + ElementReferenceNumber string `json:"elementReferenceNumber"` +} + +// SendInvoiceRequest defines the post invocie request structure +type SendInvoiceRequest struct { + InvoiceHash *InvoiceHash `json:"invoiceHash"` + InvoicePayload *InvoicePayload `json:"invoicePayload"` +} + +// InvoicePayload defines the InvoicePayload part of the post invocie request +type InvoicePayload struct { + Type string `json:"type"` + InvoiceBody string `json:"invoiceBody"` +} + +// InvoiceHash defines the InvoiceHash part of the post invocie request +type InvoiceHash struct { + HashSHA *HashSHA `json:"hashSHA"` + FileSize int `json:"fileSize"` +} + +// HashSHA defines the HashSHA part of the post invocie request +type HashSHA struct { + Algorithm string `json:"algorithm"` + Encoding string `json:"encoding"` + Value string `json:"value"` +} + +// SendInvoice puts the invoice to the KSeF API +func SendInvoice(ctx context.Context, c *Client, data []byte) (*SendInvoiceResponse, error) { + contentBase64 := base64.StdEncoding.EncodeToString(data) + + request := SendInvoiceRequest{ + InvoiceHash: &InvoiceHash{ + HashSHA: &HashSHA{ + Algorithm: "SHA-256", + Encoding: "Base64", + Value: digestBase64(data), + }, + FileSize: len(data), + }, + InvoicePayload: &InvoicePayload{ + Type: "plain", + InvoiceBody: contentBase64, + }, + } + response := &SendInvoiceResponse{} + var errorResponse ErrorResponse + resp, err := c.Client.R(). + SetResult(&response). + SetError(&errorResponse). + SetBody(request). + SetContext(ctx). + SetHeader("Content-Type", "application/json"). + SetHeader("SessionToken", c.SessionToken). + Put(c.URL + "/api/online/Invoice/Send") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, errors.New(errorResponse.Exception.ExceptionDetailList[0].ExceptionDescription) + } + return response, nil +} + +// FetchInvoiceStatus gets the status of the invoice being processed +func FetchInvoiceStatus(ctx context.Context, c *Client, referenceNumber string) (*InvoiceStatusResponse, error) { + response := &InvoiceStatusResponse{} + var errorResponse ErrorResponse + resp, err := c.Client.R(). + SetResult(response). + SetError(&errorResponse). + SetHeader("SessionToken", c.SessionToken). + SetContext(ctx). + Get(c.URL + "/api/online/Invoice/Status/" + referenceNumber) + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, errors.New(errorResponse.Exception.ExceptionDetailList[0].ExceptionDescription) + } + + return response, nil +} + +func digestBase64(content []byte) string { + digest := sha256.Sum256(content) + return base64.StdEncoding.EncodeToString(digest[:]) +} diff --git a/api/invoice_test.go b/api/invoice_test.go new file mode 100644 index 0000000..9334bbd --- /dev/null +++ b/api/invoice_test.go @@ -0,0 +1,53 @@ +package api_test + +import ( + "context" + "os" + "testing" + + ksef_api "github.com/invopop/gobl.ksef/api" + api_test "github.com/invopop/gobl.ksef/api/test" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestSendInvoice(t *testing.T) { + t.Run("should post invoice", func(t *testing.T) { + client, err := api_test.Client() + defer httpmock.DeactivateAndReset() + assert.NoError(t, err) + + elementReferenceNumber := "ExampleReferenceNumber" + httpmock.RegisterResponder("PUT", "https://ksef-test.mf.gov.pl/api/online/Invoice/Send", + httpmock.NewJsonResponderOrPanic(200, &ksef_api.SendInvoiceResponse{ElementReferenceNumber: elementReferenceNumber})) + + content, err := os.ReadFile("../test/data/out/invoice-pl-pl.xml") + assert.NoError(t, err) + + ctx := context.Background() + sendInvoiceResponse, err := ksef_api.SendInvoice(ctx, client, content) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, sendInvoiceResponse.ElementReferenceNumber, elementReferenceNumber) + }) +} + +func TestGetInvoiceStatus(t *testing.T) { + t.Run("should get invoice status", func(t *testing.T) { + client, err := api_test.Client() + defer httpmock.DeactivateAndReset() + assert.NoError(t, err) + + httpmock.RegisterResponder("GET", "https://ksef-test.mf.gov.pl/api/online/Invoice/Status/exampleReferenceNumber", + httpmock.NewJsonResponderOrPanic(200, &ksef_api.InvoiceStatusResponse{ProcessingCode: 200})) + + ctx := context.Background() + invoiceStatusResponse, err := ksef_api.FetchInvoiceStatus(ctx, client, "exampleReferenceNumber") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, invoiceStatusResponse.ProcessingCode, 200) + }) +} diff --git a/test/data/invoice-pl-pl.json b/test/data/invoice-pl-pl.json index f937ff9..3447285 100644 --- a/test/data/invoice-pl-pl.json +++ b/test/data/invoice-pl-pl.json @@ -16,7 +16,7 @@ "currency": "PLN", "supplier": { "name": "Provide One S.L.", - "tax_id": { "country": "PL", "code": "9876543210" }, + "tax_id": { "country": "PL", "code": "1234567788" }, "addresses": [ { "num": "42", diff --git a/test/data/out/invoice-pl-pl.xml b/test/data/out/invoice-pl-pl.xml index 193875e..b114a64 100644 --- a/test/data/out/invoice-pl-pl.xml +++ b/test/data/out/invoice-pl-pl.xml @@ -8,7 +8,7 @@ - 9876543210 + 1234567788 Provide One S.L. diff --git a/test/data/out/output.xml b/test/data/out/output.xml deleted file mode 100755 index 13c7fb9..0000000 --- a/test/data/out/output.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - FA - 2 - 2023-12-20T00:00:00Z - GOBL.KSEF - - - - 9876543210 - Provide One S.L. - - - PL - Calle Pradillo, 42 - 00-015, Madrid - - - - - 1234567788 - Sample Consumer - - - PL - Calle Pradillo, 43 - 00-015, Madrid - - - - PLN - 2023-12-20 - SAMPLE-001 - 1800.00 - 414.00 - 10.00 - 0.80 - 2224.80 - - 2 - 2 - 2 - 2 - - 1 - - - 1 - - 2 - - 1 - - - VAT - - 1 - Development services - HUR - 20 - 90.00 - 1800.00 - 23 - - - 2 - Financial service - E48 - 1 - 10.00 - 10.00 - 8 - - - - \ No newline at end of file From b5c50bec8a5e9a9e309b98167a2c703af89c551d Mon Sep 17 00:00:00 2001 From: Grzegorz Lisowski Date: Fri, 2 Feb 2024 14:42:42 +0100 Subject: [PATCH 3/4] Session management in KSeF API We want to be able to post invoices into the KSeF API and get back the UPO that proves that the invoice has been accepted by the system. The UPO files are generated after the interactive session is terminated. To get the files we first need to terminate the session then after it finishes generating the UPO files we can get them from the session status request. Here we the session termination and status requests used in the process. --- api/session.go | 107 ++++++++++++++++++++++++++++++++++++++++++++ api/session_test.go | 62 +++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 api/session.go create mode 100644 api/session_test.go diff --git a/api/session.go b/api/session.go new file mode 100644 index 0000000..eabd0a5 --- /dev/null +++ b/api/session.go @@ -0,0 +1,107 @@ +package api + +import ( + "context" + "encoding/xml" + "errors" +) + +// SessionStatusByReferenceResponse defines the response of the session status +type SessionStatusByReferenceResponse struct { + ProcessingCode int `json:"processingCode"` + ProcessingDescription string `json:"processingDescription"` + ReferenceNumber string `json:"referenceNumber"` + Timestamp string `json:"timestamp"` + Upo string `json:"upo"` +} + +// SessionStatusResponse defines the response of the session status +type SessionStatusResponse struct { + Timestamp string `json:"timestamp"` + ReferenceNumber string `json:"referenceNumber"` + ProcessingCode int `json:"processingCode"` + ProcessingDescription string `json:"processingDescription"` + NumberOfElements int `json:"numberOfElements"` + PageSize int `json:"pageSize"` + PageOffset int `json:"pageOffset"` + InvoiceStatusList []struct { + } `json:"invoiceStatusList"` +} + +// TerminateSessionResponse defines the response of the session termination +type TerminateSessionResponse struct { + Timestamp string `json:"timestamp"` + ReferenceNumber string `json:"referenceNumber"` + ProcessingCode int `json:"processingCode"` + ProcessingDescription string `json:"processingDescription"` +} + +// TerminateSession ends the current session +func TerminateSession(ctx context.Context, s *Client) (*TerminateSessionResponse, error) { + response := &TerminateSessionResponse{} + var errorResponse ErrorResponse + resp, err := s.Client.R(). + SetResult(response). + SetError(&errorResponse). + SetContext(ctx). + SetHeader("SessionToken", s.SessionToken). + Get(s.URL + "/api/online/Session/Terminate") + + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, errors.New(errorResponse.Exception.ExceptionDetailList[0].ExceptionDescription) + } + + return response, nil +} + +// GetSessionStatus gets the session status of the current session +func GetSessionStatus(ctx context.Context, c *Client) (*SessionStatusResponse, error) { + response := &SessionStatusResponse{} + var errorResponse ErrorResponse + resp, err := c.Client.R(). + SetResult(response). + SetError(&errorResponse). + SetContext(ctx). + SetHeader("SessionToken", c.SessionToken). + Get(c.URL + "/api/online/Session/Status") + + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, errors.New(errorResponse.Exception.ExceptionDetailList[0].ExceptionDescription) + } + + return response, nil +} + +// GetSessionStatusByReference gets the session status by reference number +func GetSessionStatusByReference(ctx context.Context, c *Client) (*SessionStatusByReferenceResponse, error) { + response := &SessionStatusByReferenceResponse{} + var errorResponse ErrorResponse + resp, err := c.Client.R(). + SetResult(response). + SetError(&errorResponse). + SetContext(ctx). + Get(c.URL + "/api/common/Status/" + c.SessionReference) + + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, errors.New(errorResponse.Exception.ExceptionDetailList[0].ExceptionDescription) + } + + return response, nil +} + +func bytes(d InitSessionTokenRequest) ([]byte, error) { + bytes, err := xml.MarshalIndent(d, "", " ") + if err != nil { + return nil, err + } + return append([]byte(``+"\n"), bytes...), nil +} diff --git a/api/session_test.go b/api/session_test.go new file mode 100644 index 0000000..148b885 --- /dev/null +++ b/api/session_test.go @@ -0,0 +1,62 @@ +package api_test + +import ( + "context" + "testing" + + ksef_api "github.com/invopop/gobl.ksef/api" + api_test "github.com/invopop/gobl.ksef/api/test" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/assert" +) + +func TestTerminateSession(t *testing.T) { + t.Run("terminates the session", func(t *testing.T) { + client, err := api_test.Client() + defer httpmock.DeactivateAndReset() + assert.NoError(t, err) + + httpmock.RegisterResponder("GET", "https://ksef-test.mf.gov.pl/api/online/Session/Terminate", + httpmock.NewJsonResponderOrPanic(200, &ksef_api.TerminateSessionResponse{ProcessingCode: 200})) + + ctx := context.Background() + terminateSessionResponse, err := ksef_api.TerminateSession(ctx, client) + assert.NoError(t, err) + + assert.Equal(t, terminateSessionResponse.ProcessingCode, 200) + }) +} + +func TestGetSessionStatus(t *testing.T) { + t.Run("returns session status", func(t *testing.T) { + client, err := api_test.Client() + defer httpmock.DeactivateAndReset() + assert.NoError(t, err) + + httpmock.RegisterResponder("GET", "https://ksef-test.mf.gov.pl/api/online/Session/Status", + httpmock.NewJsonResponderOrPanic(200, &ksef_api.SessionStatusResponse{ProcessingCode: 200})) + + ctx := context.Background() + sessionStatusResponse, err := ksef_api.GetSessionStatus(ctx, client) + assert.NoError(t, err) + + assert.Equal(t, sessionStatusResponse.ProcessingCode, 200) + }) +} + +func TestGetSessionStatusByReference(t *testing.T) { + t.Run("returns session status", func(t *testing.T) { + client, err := api_test.Client() + defer httpmock.DeactivateAndReset() + assert.NoError(t, err) + + httpmock.RegisterResponder("GET", "https://ksef-test.mf.gov.pl/api/common/Status/ExampleReferenceNumber", + httpmock.NewJsonResponderOrPanic(200, &ksef_api.SessionStatusByReferenceResponse{ProcessingCode: 200})) + + ctx := context.Background() + sessionStatusResponse, err := ksef_api.GetSessionStatusByReference(ctx, client) + assert.NoError(t, err) + + assert.Equal(t, sessionStatusResponse.ProcessingCode, 200) + }) +} From e8b405103cd2daccb6931679ef9ce758ba21636e Mon Sep 17 00:00:00 2001 From: Grzegorz Lisowski Date: Fri, 2 Feb 2024 15:06:43 +0100 Subject: [PATCH 4/4] Add posting invoice into the CLI We want to be able to post invoices into the KSeF API and get back the UPO that proves that the invoice has been accepted by the system. Here we get the parameters from the CLI and use the requests we prepared to post the invoice and save the UPO. The example command would look like go run ./cmd/gobl.ksef send ./test/data/out/invoice-pl-pl.xml 1234567788 624A48824F01935DADE66C83D4874C0EF7AF0529CB5F0F412E6932F189D3864A ./api/keys/test.pem --- cmd/gobl.ksef/main.go | 21 ++++++ cmd/gobl.ksef/root.go | 1 + cmd/gobl.ksef/send.go | 148 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 cmd/gobl.ksef/send.go diff --git a/cmd/gobl.ksef/main.go b/cmd/gobl.ksef/main.go index 79729de..8e40324 100644 --- a/cmd/gobl.ksef/main.go +++ b/cmd/gobl.ksef/main.go @@ -45,3 +45,24 @@ func inputFilename(args []string) string { } return "" } + +func inputNip(args []string) string { + if len(args) > 1 && args[1] != "-" { + return args[1] + } + return "" +} + +func inputToken(args []string) string { + if len(args) > 2 && args[2] != "-" { + return args[2] + } + return "" +} + +func inputKeyPath(args []string) string { + if len(args) > 3 && args[3] != "-" { + return args[3] + } + return "" +} diff --git a/cmd/gobl.ksef/root.go b/cmd/gobl.ksef/root.go index 003c10a..30f1418 100644 --- a/cmd/gobl.ksef/root.go +++ b/cmd/gobl.ksef/root.go @@ -22,6 +22,7 @@ func (o *rootOpts) cmd() *cobra.Command { } cmd.AddCommand(versionCmd()) + cmd.AddCommand(send(o).cmd()) cmd.AddCommand(convert(o).cmd()) return cmd diff --git a/cmd/gobl.ksef/send.go b/cmd/gobl.ksef/send.go new file mode 100644 index 0000000..d1a632d --- /dev/null +++ b/cmd/gobl.ksef/send.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "os" + "time" + + ksef_api "github.com/invopop/gobl.ksef/api" + "github.com/spf13/cobra" +) + +type sendOpts struct { + *rootOpts +} + +func send(o *rootOpts) *sendOpts { + return &sendOpts{rootOpts: o} +} + +func (c *sendOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "send [infile] [nip] [token] [keyPath]", + Short: "Send a GOBL JSON to the KSeF API", + RunE: c.runE, + } + + return cmd +} + +func (c *sendOpts) runE(cmd *cobra.Command, args []string) error { + // ctx := commandContext(cmd) + nip := inputNip(args) + token := inputToken(args) + keyPath := inputKeyPath(args) + + input, err := openInput(cmd, args) + if err != nil { + return err + } + defer func() { + err = input.Close() + if err != nil { + fmt.Fprintln(os.Stderr, err) + } + }() + + data, err := io.ReadAll(input) + if err != nil { + return fmt.Errorf("reading input: %w", err) + } + + client := ksef_api.NewClient( + ksef_api.WithID(nip), + ksef_api.WithToken(token), + ksef_api.WithKeyPath(keyPath), + ) + + _, err = SendInvoice(client, data) + if err != nil { + return fmt.Errorf("sending invoices: %w", err) + } + return nil +} + +// SendInvoice sends invoices to KSeF +func SendInvoice(c *ksef_api.Client, data []byte) (string, error) { + ctx := context.Background() + + err := ksef_api.FetchSessionToken(ctx, c) + if err != nil { + return "", err + } + + sendInvoiceResponse, err := ksef_api.SendInvoice(ctx, c, data) + if err != nil { + return "", err + } + + _, err = waitUntilInvoiceIsProcessed(ctx, c, sendInvoiceResponse.ElementReferenceNumber) + if err != nil { + return "", err + } + + res, err := waitUntilSessionIsTerminated(ctx, c) + if err != nil { + return "", err + } + upoBytes, err := base64.StdEncoding.DecodeString(res.Upo) + if err != nil { + return "", err + } + file, err := os.Create(res.ReferenceNumber + ".xml") + if err != nil { + return "", err + } + defer func() { + if err := file.Close(); err != nil { + fmt.Println("Error when closing:", err) + } + }() + _, err = file.Write(upoBytes) + if err != nil { + return "", err + } + + return string(upoBytes), nil +} + +func waitUntilInvoiceIsProcessed(ctx context.Context, c *ksef_api.Client, referenceNumber string) (*ksef_api.InvoiceStatusResponse, error) { + for { + status, err := ksef_api.FetchInvoiceStatus(ctx, c, referenceNumber) + if err != nil { + return nil, err + } + if status.ProcessingCode == 200 || status.ProcessingCode >= 400 { + return status, nil + } + sleepContext(ctx, 5*time.Second) + } +} + +func waitUntilSessionIsTerminated(ctx context.Context, c *ksef_api.Client) (*ksef_api.SessionStatusByReferenceResponse, error) { + _, err := ksef_api.TerminateSession(ctx, c) + if err != nil { + return nil, err + } + for { + status, err := ksef_api.GetSessionStatusByReference(ctx, c) + + if err != nil { + return nil, err + } + if status.ProcessingCode == 200 || status.ProcessingCode >= 400 { + return status, nil + } + sleepContext(ctx, 5*time.Second) + } +} + +func sleepContext(ctx context.Context, delay time.Duration) { + select { + case <-ctx.Done(): + case <-time.After(delay): + } +}