-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add invoice creation #6
Conversation
I would prefer to have 3 separate modules, one for invoice, one for customer and for items. |
:is_paid, | ||
:customer, | ||
:items | ||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it should at the top and we should also use enforce_keys
:
module MyMosule do
@enforce_keys [:foo, :bar]
defstruct @enforce_keys
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also considered the @enforce_keys
.
But the error message they return is very ugly.
** (ArgumentError) the following keys must also be given when building struct Car: [:make]
expanding struct: Car.__struct__/1
If we simply do not care, but make the request, the Szamlazz.hu API will return an error anyway, which we will format/pass-along anyway.
One reason I wanted to use Ecto was this, to be able to validate and provide nice error messages.
I found accidentally another package that does exactly this: https://hexdocs.pm/construct/readme.html
It looked really well, but the formatter was having a hard time with it.
And I realized it has only 50 stars, and maybe we should not include less-tested dependencies.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We are going to be building the structure inside the package, it won't be responsibility of the client, so the errors won't reach the client as well. So it's just an extra protection to make sure we don't miss any of the keys when initializing the structure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I don't get it.
What do you mean "it won't reach the client"? What will happen with such an error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How I imagine it, the client will call something like Szamlazz.create_invoice(client, %{a: b, c: d})
and then we take those params, do some basic validation (all required keys are there and they have correct types) and then build the internal records, i.e. Invoice
, Customer
and etc, so by the time we have to build those structs, we already know that all keys are there, so we shouldn't get an error that some keys were not provided. And if we do, then we have a bug. Or how do you see it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am confused.
In #1 you wrote:
I imagine we want Ecto for the validations, but the question is whether it should be our responsibility to validate the params. I am not sure, but I think we should just provide documentation, i.e. what fields we expect, which of them are mandatory, and what types they must have. The rest should be validated on the level above. Whatcha think?
Above you wrote:
we take those params, do some basic validation, [...] then build the internal records
Let's clear this.
If we offer validation for the input params, did you imagine something with guards and manual checks?
Ecto is probably an overkill for this, tho quite possibly most client applications would be using ecto anyway.
Maybe as a "peer dependency"?
The Construct package I linked can validate nicely, I am just not sure about bringing it to the repo.
I am also content with not providing validation. Maybe just in the beginning.
Because Szamlazz API is documented. They will need to consult it even if they use this library.
The Szamlazz API will validate the XML anyway.
So, I can argue that this library should be a thin wrapper on top of the Szamlazz API. Just a messenger.
I tried checking ExAws code. My impression is that it uses AWS to validate the request, and raises in rare cases when the request makes no sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should be doing any thorough validations, i.e. making sure that the provided values are actually something that their server expects. We just check that the required keys are provided and maybe? that they have correct types, everything else is not out responsibility, how I see it. And for that, we don't need Ecto or any other package. And then we should also be careful with extracting the errors from the response, if the client provided incorrect data and the server rejected it.
So something like that, I hope it clears things out.
xsi:schemaLocation="http://www.szamlazz.hu/xmlszamla https://www.szamlazz.hu/szamla/docs/xsds/agent/xmlszamla.xsd" | ||
> | ||
<beallitasok> | ||
<szamlaagentkulcs>#{invoice_data.agent_key}</szamlaagentkulcs> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think this agent_key
should come as part of the invoice. That is, if it's going to be used in other places.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I probably don't understand how you would do it.
Having it as a global configuration is something that is discouraged, as far as I understood.
The main cited reason is that if your library is used by multiple dependencies, then one will overwrite the configuration of the other.
In our case, I can imagine an application that wants to create invoices for multiple customers, where each customer has their own API_KEY.
I was considering a client-based approach, e.g:
client = ExSzamlazzHu.client(api_key: "secret")
ExSzamlazzHu.create_invoice(client, %{})
But then I realized that the key can be part of the params.
What would be your approach and why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I definitely like that much more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't know about this issue with the global config, I will read about it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but...
why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because this way we separate params that are specific to the operation from the global params that are required for each operation.
So basically:
Szamlazz.create_invoice(client, %{amount: 100})
seems better than mixing two domains:
Szamlazz.create_invoice(%{amount: 100, agent_key: "sadfsfd"})
And it's easier to reuse the config as well, you just build it once and then use for each operation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, it feels more professional to have something like
Szamlazz.create_invoice(client, %{amount: 100})
and it indeed seems better than mixing two domains.
But the Szamlazz API mixes the domains. Rather than authorizing yourself and receiving a token/client, you put your api key into the XML each time. With every request, the client needs to send the credentials, settings and the invoice parameters.
I think there is value in following the Szamlazz API as close as possible, and providing only a thin wrapper. Something that hides the fact that the communication is with XML payloads and the response is plain text.
So this library can take an object (structured similarly to the XML), and return proper error codes and error messages.
So, at least in the beginning, I would like to go with the less elegant approach.
- closer to the logic of the API, so the Szamlazz documentation will be more meaningful for the users of this library
- easier input param validation, because we expect params in the structure of the XML that we are going to build
Once we handle every request, it can be a good point to look for optimization possibilities.
I am for it, I just don't think it is the right time.
It can even be a major change, but let's do it once we have the internals working.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't know about that -> response is plain text
. That's fucked up.
Frankly, I don't know that much about their API, you had way more experience with it, so if you feel it should be closer to how they manage things, then it's ultimate your call. I am mostly a sidekick here.
@@ -0,0 +1,42 @@ | |||
defmodule ExSzamlazzHu.Modules.CreateInvoice.InvoiceData do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like InvoiceData
, the Data
part doesn't provide any information.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay...
It is "something" that will be sent to the API so that an invoice is created from it.
It cannot be named Invoice
because it is not an invoice.
It may be InvoiceParams
, as they are the parameters we expected to receive.
And since this is in the scope of "CreateInvoice" maybe that prefix can be omitted: Params
.
Or I still think it can be InvoiceData
, because it carries data that should be presented on the invoice.
What else?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I see, that's a good point.
What if we call it Header
then, since that's what it looks like to be for. And then unnest the other structs and ask the client to provide three structs separately:
create_invoice(%{header: header, customer: customer, items: items})
Also the type definitions should show what values can be nil, coz I think not all the fields are always mandatory. |
Yes. I will read thru the XSD and update the types accordingly. |
I pushed a commit where I added my idea for validation. I did it differently than what you suggested: I first parse the params into the struct and then validate it. params = %{settings: %{e_invoice: true, download_invoice: false, response_version: "3"}}
params
|> ExSzamlazzHu.Modules.CreateInvoice.InvoiceData.parse()
|> ExSzamlazzHu.Modules.CreateInvoice.InvoiceData.validate()
# --> {:error, %{settings: %{response_version: :invalid}}} It is simple, it returns either # invoice_data.ex
def validate(struct) do
%{
settings: &Settings.validate/1
}
|> Validator.validate(struct)
end
# settings.ex
@spec validate(t()) :: boolean()
def validate(struct) do
%{
user: &(is_nil(&1) or is_binary(&1)),
password: &(is_nil(&1) or is_binary(&1)),
agent_key: &(is_nil(&1) or is_binary(&1)),
e_invoice: &is_boolean/1,
download_invoice: &is_boolean/1,
download_invoice_number_of_copies: &(is_nil(&1) or is_integer(&1)),
response_version: &(is_nil(&1) or is_integer(&1)),
aggregator: &(is_nil(&1) or is_binary(&1)),
guardian: &(is_nil(&1) or is_boolean(&1)),
article_identifier_invoice: &(is_nil(&1) or is_boolean(&1)),
external_invoice_identifier: &(is_nil(&1) or is_binary(&1))
}
|> Validator.validate(struct)
end If you have a different idea, would you show a code snippet, please? I think this is simple enough. |
f82c882
to
9b29da8
Compare
I would prefer to have something more well structured, for example what they have here -> https://pspdfkit.com/blog/2020/declarative-validation-with-elixir/ Maybe a bit simpler though. |
I'd consider using https://github.com/thoughtbot/ex_machina/ |
weight: :invalid, | ||
extra_services: :invalid, | ||
value_statement: :invalid | ||
}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like those sheets of data structures, maybe it's just me but it's not extremely readable.
params = %{..}
assert {:error, error} = validate()
assert error.barcode == :invalid
....
41fa548
to
b2f7a42
Compare
ba70aa3
to
3fe6010
Compare
| Query receipt | ❌ | | ||
| Send receipt | ❌ | | ||
| Query taxpayers | ❌ | | ||
| Create supplier account | ❌ | |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's cute
lib/modules/create_invoice.ex
Outdated
@@ -0,0 +1,135 @@ | |||
defmodule ExSzamlazzHu.Modules.CreateInvoice do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the Modules
namespace for?
defp save_temporary_file(xml) do | ||
random_chars = for _ <- 1..5, into: "", do: <<Enum.random(?a..?z)>> | ||
timestamp = DateTime.utc_now() |> DateTime.to_iso8601() | ||
filename = "#{timestamp}_#{random_chars}.xml" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have a feeling you will be reusing this logic, so probably makes sense to put it somewhere else
lib/modules/create_invoice.ex
Outdated
result <- compile_result(success, data, response) do | ||
{:ok, result} | ||
else | ||
{:error, :cannot_save_temporary_file} -> {:error, :cannot_save_temporary_file} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ugh
header_map = Map.new(response.headers) | ||
|
||
cond do | ||
header_map["szlahu_down"] == "true" -> {:ok, false, %{szlahu_down: true}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It will be reused in all the requests, I imagine.
lib/modules/create_invoice.ex
Outdated
szlahu_kintlevoseg: header_map["szlahu_kintlevoseg"], | ||
szlahu_vevoifiokurl: header_map["szlahu_vevoifiokurl"] | ||
} | ||
|> maybe_add_invoice_path_info(response, invoice_data) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would split it into 2 variables:
params = %{..}
maybe_add_invoice(params)
Would be prettier than a pipe, IMHO
|
||
defp get_invoice_pdf_data( | ||
%Tesla.Env{} = response, | ||
%InvoiceData{beallitasok: %{szamlaLetoltes: true, valaszVerzio: 2}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know you and Muriel have this cult of hating comments, but it would be cool to have some note about the thing with those versions 🙄
@spec to_xml(t()) :: String.t() | ||
def to_xml(%__MODULE__{} = module) do | ||
tags = [ | ||
nev: &"<nev>#{&1}</nev>", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First time I see this format, believe me or not.
lib/modules/create_invoice/header.ex
Outdated
:elonezetpdf | ||
] | ||
|
||
@spec parse(map()) :: t() | nil |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably should be
@spec parse(map() | nil) :: t() | nil
lib/utils/struct_to_xml.ex
Outdated
nil -> nil | ||
value -> fun.(value) | ||
end | ||
end) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can make this pipe prettier 😄👍🏻
The parameters follow the same shape as described in the Szamlazz.hu API documentation.
The result of the call is a struct, which - among other things - contains the original response from the Szamlazz.hu API.
I added tests hitting the szamlazz.hu API, they are tagged with
:external
and this tag is exlcuded frommix test
by default, but they run on the CI.We are using Tesla, so that the actual http lib can be chosen by the user.
I configured the repo to use Hackney instead of the default
httpc
(for dev and test) when I saw that we receive{:error, :remotely_closed_by_peer}
errors. That didn't solve it, and we now receive{:error, :closed}
randomly. I added a workaround to attempt the calls twice in the tests. I have this issue in production apps too, so maybe it is a szamlazz.hu issue...