diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 0000000..9cb74a7 --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,2 @@ +extends: + - "@commitlint/config-conventional" diff --git a/.gitignore b/.gitignore index 923d5eb..2bd1644 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ go.work .idea bin + +invoices.json diff --git a/.golangci.yml b/.golangci.yml index 5f5c74c..c6cc4a3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -58,7 +58,7 @@ linters-settings: tagliatelle: case: rules: - json: camel + json: snake lll: line-length: 150 gocyclo: diff --git a/cmd/infakt-cli/main.go b/cmd/infakt-cli/main.go deleted file mode 100644 index c115842..0000000 --- a/cmd/infakt-cli/main.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "log" - "os" - - "github.com/urfave/cli/v2" -) - -const ( - appName = "infakt-cli" -) - -func main() { - app := cli.NewApp() - app.Name = appName - app.Usage = "CLI tool for interacting with the InFakt API" - app.HelpName = appName - app.EnableBashCompletion = true - - if err := app.Run(os.Args); err != nil { - log.Fatal(err) - } -} diff --git a/go.mod b/go.mod index a975b71..671a786 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module gitea.dwysokinski.me/Kichiyaki/infakt-cli go 1.20 -require github.com/urfave/cli/v2 v2.25.1 +require ( + github.com/urfave/cli/v2 v2.25.1 + golang.org/x/time v0.3.0 +) require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect diff --git a/go.sum b/go.sum index f003bf9..0a0b1e0 100644 --- a/go.sum +++ b/go.sum @@ -6,3 +6,5 @@ github.com/urfave/cli/v2 v2.25.1 h1:zw8dSP7ghX0Gmm8vugrs6q9Ku0wzweqPyshy+syu9Gw= github.com/urfave/cli/v2 v2.25.1/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/infakt/client.go b/internal/infakt/client.go new file mode 100644 index 0000000..cee83ed --- /dev/null +++ b/internal/infakt/client.go @@ -0,0 +1,168 @@ +package infakt + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + urlpkg "net/url" + "time" + + "golang.org/x/time/rate" +) + +const ( + defaultBaseURL = "https://api.infakt.pl" + defaultTimeout = 10 * time.Second + //nolint:gosec + apiKeyHeader = "X-inFakt-ApiKey" + reqPerMinute = 150 + endpointCreateInvoice = "/api/v3/invoices.json" +) + +type Client struct { + baseURL string + apiKey string + client *http.Client + rateLimiter *rate.Limiter +} + +type ClientOption func(c *Client) + +func WithBaseURL(s string) ClientOption { + return func(c *Client) { + c.baseURL = s + } +} + +func WithHTTPClient(hc *http.Client) ClientOption { + return func(c *Client) { + c.client = hc + } +} + +func NewClient(apiKey string, opts ...ClientOption) *Client { + c := &Client{ + baseURL: defaultBaseURL, + apiKey: apiKey, + client: &http.Client{ + Timeout: defaultTimeout, + }, + rateLimiter: rate.NewLimiter(rate.Every(time.Minute), reqPerMinute), + } + + for _, opt := range opts { + opt(c) + } + + return c +} + +type CreateInvoiceParamsService struct { + Name string `json:"name"` + TaxSymbol string `json:"tax_symbol,omitempty"` + Unit string `json:"unit,omitempty"` + Quantity float64 `json:"quantity,omitempty"` + UnitNetPrice int `json:"unit_net_price,omitempty"` + FlatRateTaxSymbol string `json:"flat_rate_tax_symbol,omitempty"` +} + +type CreateInvoiceParams struct { + Currency string `json:"currency,omitempty"` + Kind string `json:"kind,omitempty"` + PaymentMethod string `json:"payment_method,omitempty"` + InvoiceDate string `json:"invoice_date,omitempty"` + SaleDate string `json:"sale_date,omitempty"` + PaymentDate string `json:"payment_date,omitempty"` + ClientID int `json:"client_id,omitempty"` + ClientCompanyName string `json:"client_company_name,omitempty"` + ClientFirstName string `json:"client_first_name,omitempty"` + ClientBusinessActivityKind string `json:"client_business_activity_kind,omitempty"` + ClientStreet string `json:"client_street,omitempty"` + ClientStreetNumber string `json:"client_street_number,omitempty"` + ClientFlatNumber string `json:"client_flat_number,omitempty"` + ClientCity string `json:"client_city,omitempty"` + ClientPostCode string `json:"client_post_code,omitempty"` + ClientTaxCode string `json:"client_tax_code,omitempty"` + ClientCountry string `json:"client_country,omitempty"` + SaleType string `json:"sale_type,omitempty"` + SellerSignature string `json:"seller_signature"` + InvoiceDateKind string `json:"invoice_date_kind,omitempty"` + Services []CreateInvoiceParamsService `json:"services,omitempty"` + VatExemptionReason int `json:"vat_exemption_reason,omitempty"` + BankAccount string `json:"bank_account,omitempty"` + BankName string `json:"bank_name,omitempty"` + Swift string `json:"swift,omitempty"` +} + +type Invoice struct { + ID int `json:"id"` + UUID string `json:"uuid"` + Number string `json:"number"` +} + +func (c *Client) CreateInvoice(ctx context.Context, params CreateInvoiceParams) (Invoice, error) { + resp, err := c.postJSON(ctx, endpointCreateInvoice, map[string]any{ + "invoice": params, + }) + if err != nil { + return Invoice{}, err + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + var invoice Invoice + if err = json.NewDecoder(resp.Body).Decode(&invoice); err != nil { + return Invoice{}, err + } + + return invoice, nil +} + +func (c *Client) postJSON(ctx context.Context, url string, body any) (*http.Response, error) { + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(body); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, buf) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + return c.do(req) +} + +func (c *Client) do(req *http.Request) (*http.Response, error) { + url, err := urlpkg.Parse(c.baseURL + req.URL.String()) + if err != nil { + return nil, err + } + + req.URL = url + req.Header.Set(apiKeyHeader, c.apiKey) + + if err = c.rateLimiter.Wait(req.Context()); err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode >= http.StatusBadRequest { + b, _ := io.ReadAll(resp.Body) + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + return nil, fmt.Errorf("request failed with status=%d and body=%s", resp.StatusCode, b) + } + + return resp, nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..81e1879 --- /dev/null +++ b/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "encoding/json" + "log" + "os" + + "gitea.dwysokinski.me/Kichiyaki/infakt-cli/internal/infakt" + "github.com/urfave/cli/v2" +) + +const ( + appName = "infakt-cli" +) + +func main() { + app := cli.NewApp() + app.Name = appName + app.Usage = "CLI tool for interacting with the InFakt API" + app.HelpName = appName + app.Flags = []cli.Flag{ + &cli.StringFlag{ + Name: "url", + Aliases: []string{"u"}, + DefaultText: "https://api.infakt.pl", + }, + &cli.StringFlag{ + Name: "apiKey", + EnvVars: []string{"INFAKT_API_KEY"}, + Required: true, + }, + } + app.Commands = []*cli.Command{ + newInvoiceCmd(), + } + app.EnableBashCompletion = true + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +func newInvoiceCmd() *cli.Command { + return &cli.Command{ + Name: "invoice", + Subcommands: []*cli.Command{ + { + Name: "create", + Action: func(c *cli.Context) error { + var clientOpts []infakt.ClientOption + if url := c.String("url"); url != "" { + clientOpts = append(clientOpts, infakt.WithBaseURL(url)) + } + + b, err := os.ReadFile(c.Path("file")) + if err != nil { + return err + } + + var params []infakt.CreateInvoiceParams + if err = json.Unmarshal(b, ¶ms); err != nil { + return err + } + + client := infakt.NewClient(c.String("apiKey"), clientOpts...) + + for i, p := range params { + invoice, err := client.CreateInvoice(c.Context, p) + if err != nil { + log.Printf("couldn't create invoice #%d: %s", i+1, err) + continue + } + log.Printf("invoice #%d created with number %s", i+1, invoice.Number) + } + + return nil + }, + Flags: []cli.Flag{ + &cli.PathFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "Path to .json file with invoice params", + Required: true, + }, + }, + }, + }, + } +}