ksef-client-rails 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/NOTICE +40 -0
- data/README.md +524 -6
- data/examples/fa3_full.xml +143 -0
- data/examples/fa3_minimal.xml +65 -0
- data/examples/fa3_typical.xml +76 -0
- data/lib/generators/ksef/install/install_generator.rb +20 -0
- data/lib/generators/ksef/install/templates/ksef.rb +25 -0
- data/lib/ksef/auth/coordinator.rb +164 -0
- data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
- data/lib/ksef/batch/package_builder.rb +125 -0
- data/lib/ksef/batch/splitter.rb +99 -0
- data/lib/ksef/batch/uploader.rb +114 -0
- data/lib/ksef/client.rb +135 -0
- data/lib/ksef/configuration.rb +206 -0
- data/lib/ksef/crypto/cryptography_service.rb +136 -0
- data/lib/ksef/crypto/csr.rb +106 -0
- data/lib/ksef/crypto/public_keys.rb +125 -0
- data/lib/ksef/environments.rb +61 -0
- data/lib/ksef/errors.rb +187 -0
- data/lib/ksef/http/connection.rb +432 -0
- data/lib/ksef/http/error_parser.rb +194 -0
- data/lib/ksef/http/pagination.rb +80 -0
- data/lib/ksef/invoices/builder/checks.rb +127 -0
- data/lib/ksef/invoices/builder/number.rb +67 -0
- data/lib/ksef/invoices/builder/xml.rb +256 -0
- data/lib/ksef/invoices/builder.rb +251 -0
- data/lib/ksef/invoices/export.rb +241 -0
- data/lib/ksef/invoices/sample.rb +78 -0
- data/lib/ksef/invoices/schema_outline.rb +120 -0
- data/lib/ksef/invoices/validator.rb +200 -0
- data/lib/ksef/models/form_code.rb +49 -0
- data/lib/ksef/models/ksef_number.rb +74 -0
- data/lib/ksef/models/nip.rb +62 -0
- data/lib/ksef/polish_time.rb +49 -0
- data/lib/ksef/polling.rb +65 -0
- data/lib/ksef/qr/code.rb +104 -0
- data/lib/ksef/qr/verification_link.rb +215 -0
- data/lib/ksef/railtie.rb +20 -0
- data/lib/ksef/resources/auth.rb +116 -0
- data/lib/ksef/resources/base.rb +120 -0
- data/lib/ksef/resources/batch_sessions.rb +43 -0
- data/lib/ksef/resources/certificates.rb +82 -0
- data/lib/ksef/resources/collective_identifiers.rb +70 -0
- data/lib/ksef/resources/exports.rb +47 -0
- data/lib/ksef/resources/invoices.rb +67 -0
- data/lib/ksef/resources/limits.rb +33 -0
- data/lib/ksef/resources/online_sessions.rb +62 -0
- data/lib/ksef/resources/peppol.rb +21 -0
- data/lib/ksef/resources/permissions.rb +180 -0
- data/lib/ksef/resources/security.rb +18 -0
- data/lib/ksef/resources/sessions.rb +125 -0
- data/lib/ksef/resources/test_data.rb +178 -0
- data/lib/ksef/resources/tokens.rb +62 -0
- data/lib/ksef/sessions/online.rb +168 -0
- data/lib/ksef/sync/incremental.rb +169 -0
- data/lib/ksef/testing.rb +153 -0
- data/lib/ksef/token_manager.rb +130 -0
- data/lib/ksef/version.rb +11 -0
- data/lib/ksef/xades/auth_token_request.rb +135 -0
- data/lib/ksef/xades/external_signer.rb +58 -0
- data/lib/ksef/xades/signer.rb +312 -0
- data/lib/ksef-client-rails.rb +3 -2
- data/lib/ksef.rb +116 -0
- data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
- data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
- data/schemas/README.md +53 -0
- data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
- data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
- data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
- data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
- metadata +291 -9
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
module Http
|
|
5
|
+
# The two KSeF pagination mechanisms, split along "query vs list":
|
|
6
|
+
# {.by_offset} uses `pageOffset` (a 0-based page INDEX, not a record offset) + `hasMore`;
|
|
7
|
+
# {.by_continuation} reads `continuationToken` from the body but sends it in the
|
|
8
|
+
# `x-continuation-token` header. Both return a lazy enumerator, so pages are fetched on demand.
|
|
9
|
+
module Pagination
|
|
10
|
+
# A single page of offset-paginated results; +truncated+ marks the server's record limit.
|
|
11
|
+
OffsetPage = Struct.new(:items, :has_more, :truncated, keyword_init: true)
|
|
12
|
+
# A single page of continuation-token-paginated results.
|
|
13
|
+
ContinuationPage = Struct.new(:items, :continuation_token, keyword_init: true)
|
|
14
|
+
# Empty continuation pages in a row after which iteration gives up.
|
|
15
|
+
MAX_EMPTY_PAGES = 100
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Iterates over pages incrementing the page index by 1 while `has_more` is truthy; an empty page
|
|
20
|
+
# ends the iteration too, so a server that keeps answering `hasMore` cannot loop it forever. A
|
|
21
|
+
# truncated page with more behind it raises once its items are consumed: the next page index
|
|
22
|
+
# would not continue the result.
|
|
23
|
+
#
|
|
24
|
+
# @param page_size [Integer] page size
|
|
25
|
+
# @param start_page [Integer] starting page index (defaults to 0)
|
|
26
|
+
# @param truncated_hint [String] how to continue past a truncated result, for the error message
|
|
27
|
+
# @yieldparam page_index [Integer]
|
|
28
|
+
# @yieldparam page_size [Integer]
|
|
29
|
+
# @yieldreturn [OffsetPage]
|
|
30
|
+
# @return [Enumerator::Lazy]
|
|
31
|
+
# @raise [Ksef::ResultTruncated] when iteration goes past a truncated page
|
|
32
|
+
def by_offset(page_size: 10, start_page: 0, truncated_hint: "narrow the query")
|
|
33
|
+
Enumerator.new do |yielder|
|
|
34
|
+
page_index = start_page
|
|
35
|
+
loop do
|
|
36
|
+
page = yield(page_index, page_size)
|
|
37
|
+
items = Array(page.items)
|
|
38
|
+
items.each { |item| yielder << item }
|
|
39
|
+
break if !page.has_more || items.empty?
|
|
40
|
+
if page.truncated
|
|
41
|
+
raise ResultTruncated.new("The result reached the record limit (isTruncated) — #{truncated_hint}",
|
|
42
|
+
last_item: items.last)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
page_index += 1
|
|
46
|
+
end
|
|
47
|
+
end.lazy
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Iterates over pages passing the continuation token for as long as the server returns a new one. A
|
|
51
|
+
# page may be empty and still continue; a token returned before ends the iteration (it would loop
|
|
52
|
+
# forever), and so many empty pages in a row raise.
|
|
53
|
+
#
|
|
54
|
+
# @param page_size [Integer] page size
|
|
55
|
+
# @yieldparam continuation_token [String, nil] nil on the first request
|
|
56
|
+
# @yieldparam page_size [Integer]
|
|
57
|
+
# @yieldreturn [ContinuationPage]
|
|
58
|
+
# @return [Enumerator::Lazy]
|
|
59
|
+
# @raise [Ksef::Error] after {MAX_EMPTY_PAGES} empty pages in a row
|
|
60
|
+
def by_continuation(page_size: 10)
|
|
61
|
+
Enumerator.new do |yielder|
|
|
62
|
+
seen = Set.new
|
|
63
|
+
empty = 0
|
|
64
|
+
token = nil
|
|
65
|
+
loop do
|
|
66
|
+
page = yield(token, page_size)
|
|
67
|
+
items = Array(page.items)
|
|
68
|
+
items.each { |item| yielder << item }
|
|
69
|
+
empty = items.empty? ? empty + 1 : 0
|
|
70
|
+
raise Error, "KSeF returned #{empty} empty pages in a row; stopping" if empty >= MAX_EMPTY_PAGES
|
|
71
|
+
|
|
72
|
+
seen << token if token
|
|
73
|
+
token = page.continuation_token
|
|
74
|
+
break if token.nil? || token.to_s.empty? || seen.include?(token)
|
|
75
|
+
end
|
|
76
|
+
end.lazy
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
module Invoices
|
|
5
|
+
class Builder
|
|
6
|
+
# The checks {Builder#to_xml} runs before rendering: required values, numbers, codes and text.
|
|
7
|
+
# @api private
|
|
8
|
+
module Checks
|
|
9
|
+
# Rates with VAT, which a foreign-currency invoice would also have to state in PLN (`P_14_xW`).
|
|
10
|
+
VAT_RATES = %w[23 8 5].freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def check!
|
|
15
|
+
check_parties!
|
|
16
|
+
raise ConfigurationError, "at least one line is required" if lines.empty?
|
|
17
|
+
|
|
18
|
+
check_required!(number: number, issue_date: issue_date)
|
|
19
|
+
check_codes!
|
|
20
|
+
check_lines!
|
|
21
|
+
check_tax!
|
|
22
|
+
check_correction!
|
|
23
|
+
check_payment!
|
|
24
|
+
check_text!
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def check_parties!
|
|
28
|
+
raise ConfigurationError, "seller and buyer are required" unless seller && buyer
|
|
29
|
+
|
|
30
|
+
check_required!("seller name": seller.name, "seller address": seller.address,
|
|
31
|
+
"buyer name": buyer.name, "buyer address": buyer.address)
|
|
32
|
+
id_type = buyer.id_type.to_s
|
|
33
|
+
unless id_type.empty? || BUYER_ID_TYPES.include?(id_type)
|
|
34
|
+
raise ConfigurationError, "Unknown buyer id_type: #{id_type}"
|
|
35
|
+
end
|
|
36
|
+
return unless %w[eu other].include?(buyer.identification) && blank?(buyer.country)
|
|
37
|
+
|
|
38
|
+
raise ConfigurationError, "buyer country is required for a buyer identified by id_type " \
|
|
39
|
+
"#{buyer.identification.inspect} (Adres/KodKraju)"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def check_required!(**values)
|
|
43
|
+
missing = values.select { |_, value| blank?(value) }.keys
|
|
44
|
+
raise ConfigurationError, "#{missing.join(', ')} required" unless missing.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check_codes!
|
|
48
|
+
unknown = annotations.map(&:to_sym) - ANNOTATIONS.keys
|
|
49
|
+
raise ConfigurationError, "Unknown annotation(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
50
|
+
raise ConfigurationError, "Unknown invoice_type: #{invoice_type}" unless INVOICE_TYPES.include?(invoice_type)
|
|
51
|
+
unless blank?(correction_type) || CORRECTION_TYPES.include?(correction_type.to_s)
|
|
52
|
+
raise ConfigurationError, "Unknown correction_type: #{correction_type}"
|
|
53
|
+
end
|
|
54
|
+
raise ConfigurationError, "Unknown currency: #{currency}" unless CURRENCY.match?(currency.to_s)
|
|
55
|
+
|
|
56
|
+
[seller, buyer].each do |party|
|
|
57
|
+
next if blank?(party.country) || COUNTRY.match?(party.country.to_s)
|
|
58
|
+
|
|
59
|
+
raise ConfigurationError, "Unknown country: #{party.country.inspect} (an ISO 3166 code such as \"DE\")"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_lines!
|
|
64
|
+
unknown_rates = lines.map(&:rate).uniq - RATES.keys
|
|
65
|
+
raise ConfigurationError, "Unknown VAT rate(s): #{unknown_rates.join(', ')}" unless unknown_rates.empty?
|
|
66
|
+
|
|
67
|
+
lines.each.with_index(1) do |line, index|
|
|
68
|
+
line.net
|
|
69
|
+
next if blank?(line.gtu) || GTU_CODES.include?(line.gtu.to_s)
|
|
70
|
+
|
|
71
|
+
raise ConfigurationError, "Unknown GTU code: #{line.gtu} (GTU_01 … GTU_13)"
|
|
72
|
+
rescue ConfigurationError => e
|
|
73
|
+
raise ConfigurationError, "line #{index}: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_tax!
|
|
78
|
+
if exempt? && blank?(exemption_reason)
|
|
79
|
+
raise ConfigurationError, "exemption_reason is required for a zw line — the legal basis of the " \
|
|
80
|
+
"exemption (P_19A), e.g. \"art. 113 ust. 1 ustawy o VAT\""
|
|
81
|
+
end
|
|
82
|
+
return if currency == "PLN" || lines.none? { |line| VAT_RATES.include?(line.rate) }
|
|
83
|
+
|
|
84
|
+
raise ConfigurationError, "a #{currency} invoice with a 23/8/5% line must also state the VAT in PLN " \
|
|
85
|
+
"(P_14_xW), which the builder does not write — issue it in PLN"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_correction!
|
|
89
|
+
return unless correction?
|
|
90
|
+
|
|
91
|
+
check_required!(corrected_date: corrected_date, corrected_number: corrected_number)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check_payment!
|
|
95
|
+
payment_code
|
|
96
|
+
flag = paid_flag
|
|
97
|
+
raise ConfigurationError, "paid_date is required when paid is true" if flag && blank?(paid_date)
|
|
98
|
+
raise ConfigurationError, "paid_date is given but paid is not true" if !flag && !blank?(paid_date)
|
|
99
|
+
raise ConfigurationError, "bank_name needs bank_account" if blank?(bank_account) && !blank?(bank_name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Every String that ends up in the XML: valid UTF-8, no character XML or KSeF refuses.
|
|
103
|
+
def check_text!
|
|
104
|
+
texts = to_h.except(:seller, :buyer, :lines).map { |name, value| [name.to_s, value] }
|
|
105
|
+
{ "seller" => seller, "buyer" => buyer }.each do |role, party|
|
|
106
|
+
texts.concat(party.to_h.map { |name, value| ["#{role} #{name}", value] })
|
|
107
|
+
end
|
|
108
|
+
lines.each.with_index(1) do |line, index|
|
|
109
|
+
texts.concat(line.to_h.map { |name, value| ["line #{index} #{name}", value] })
|
|
110
|
+
end
|
|
111
|
+
texts.each { |label, value| check_string!(label, value) if value.is_a?(String) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def check_string!(label, value)
|
|
115
|
+
unless [Encoding::UTF_8, Encoding::US_ASCII].include?(value.encoding) && value.valid_encoding?
|
|
116
|
+
raise ConfigurationError, "#{label} is not a UTF-8 String (#{value.encoding}) — " \
|
|
117
|
+
"convert it with String#encode(\"UTF-8\")"
|
|
118
|
+
end
|
|
119
|
+
bad = value[XML_DISALLOWED] || value[Validator::DISALLOWED_CHARACTERS]
|
|
120
|
+
return unless bad
|
|
121
|
+
|
|
122
|
+
raise ConfigurationError, "#{label} contains #{format('U+%04X', bad.ord)}, a character KSeF refuses"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Ksef
|
|
6
|
+
module Invoices
|
|
7
|
+
class Builder
|
|
8
|
+
# Reads and writes the builder's numbers exactly — never through Float.
|
|
9
|
+
# @api private
|
|
10
|
+
module Number
|
|
11
|
+
DECIMAL = /\A[+-]?\d+(?:[.,]\d+)?\z/
|
|
12
|
+
# Thousands grouped by a space, a no-break space or a narrow no-break space: "1 234,56".
|
|
13
|
+
GROUPED = /\A[+-]?\d{1,3}(?:[ ]\d{3})+(?:[.,]\d+)?\z/
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param value [Integer, BigDecimal, Float, String, nil]
|
|
18
|
+
# @param field [String] the name used in the error message
|
|
19
|
+
# @param places [Integer] the most decimal places allowed
|
|
20
|
+
# @return [BigDecimal]
|
|
21
|
+
# @raise [Ksef::ConfigurationError] for a blank, malformed or non-finite value, or more decimal places
|
|
22
|
+
def parse(value, field, places:)
|
|
23
|
+
text = canonical(value)
|
|
24
|
+
raise ConfigurationError, "#{field} is required" if text.nil?
|
|
25
|
+
raise ConfigurationError, "#{field} is not a decimal number: #{value.inspect}" unless DECIMAL.match?(text)
|
|
26
|
+
|
|
27
|
+
number = BigDecimal(text.tr(",", "."))
|
|
28
|
+
unless number.round(places) == number
|
|
29
|
+
raise ConfigurationError, "#{field} has more than #{places} decimal places: #{value.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
number
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param value [BigDecimal]
|
|
36
|
+
# @return [String] the amount with 2 decimal places, rounded half up ("100.00", "-0.50")
|
|
37
|
+
def amount(value)
|
|
38
|
+
rounded = value.round(2, BigDecimal::ROUND_HALF_UP)
|
|
39
|
+
rounded = BigDecimal("0") if rounded.zero?
|
|
40
|
+
whole, fraction = rounded.to_s("F").split(".")
|
|
41
|
+
"#{whole}.#{fraction.ljust(2, '0')}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param value [BigDecimal]
|
|
45
|
+
# @return [String] the number without a trailing ".0" ("2", "1.5")
|
|
46
|
+
def plain(value)
|
|
47
|
+
value.to_s("F").delete_suffix(".0")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def canonical(value)
|
|
51
|
+
case value
|
|
52
|
+
when nil then nil
|
|
53
|
+
when Integer then value.to_s
|
|
54
|
+
when BigDecimal, Float then value.finite? ? BigDecimal(value.to_s).to_s("F") : value.to_s
|
|
55
|
+
when String
|
|
56
|
+
text = value.gsub(/\A[[:space:]]+|[[:space:]]+\z/, "")
|
|
57
|
+
return nil if text.empty?
|
|
58
|
+
|
|
59
|
+
GROUPED.match?(text) ? text.delete(" ") : text
|
|
60
|
+
else value.inspect
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
private_class_method :canonical
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "forwardable"
|
|
5
|
+
require "nokogiri"
|
|
6
|
+
|
|
7
|
+
module Ksef
|
|
8
|
+
module Invoices
|
|
9
|
+
class Builder
|
|
10
|
+
# Writes a {Builder}'s invoice as FA(3) XML, element by element in the schema's sequence.
|
|
11
|
+
# @api private
|
|
12
|
+
class Xml
|
|
13
|
+
extend Forwardable
|
|
14
|
+
|
|
15
|
+
def_delegators :@invoice, *(ATTRIBUTES - %i[annotations]), :totals_by_rate, :gross_total, :correction?,
|
|
16
|
+
:annotation?, :exempt?, :payment_code, :paid_flag
|
|
17
|
+
|
|
18
|
+
# @param invoice [Builder]
|
|
19
|
+
def initialize(invoice)
|
|
20
|
+
@invoice = invoice
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param created_at [Time]
|
|
24
|
+
# @return [String]
|
|
25
|
+
def render(created_at)
|
|
26
|
+
Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
|
|
27
|
+
xml.Faktura(xmlns: FormCode::FA3.namespace) do
|
|
28
|
+
header(xml, created_at)
|
|
29
|
+
seller_element(xml)
|
|
30
|
+
buyer_element(xml)
|
|
31
|
+
invoice_body(xml)
|
|
32
|
+
footer_element(xml)
|
|
33
|
+
end
|
|
34
|
+
end.to_xml
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def header(xml, created_at)
|
|
40
|
+
xml.Naglowek do
|
|
41
|
+
xml.KodFormularza("FA", kodSystemowy: FormCode::FA3.system_code, wersjaSchemy: FormCode::FA3.schema_version)
|
|
42
|
+
xml.WariantFormularza "3"
|
|
43
|
+
xml.DataWytworzeniaFa created_at.getutc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
44
|
+
xml.SystemInfo system_info
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def seller_element(xml)
|
|
49
|
+
xml.Podmiot1 do
|
|
50
|
+
xml.DaneIdentyfikacyjne do
|
|
51
|
+
xml.NIP Nip.normalize!(seller.nip, name: "seller NIP")
|
|
52
|
+
xml.Nazwa seller.name
|
|
53
|
+
end
|
|
54
|
+
address(xml, seller)
|
|
55
|
+
contact(xml, seller)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def buyer_element(xml)
|
|
60
|
+
xml.Podmiot2 do
|
|
61
|
+
xml.DaneIdentyfikacyjne do
|
|
62
|
+
buyer_identifier(xml, buyer)
|
|
63
|
+
xml.Nazwa buyer.name
|
|
64
|
+
end
|
|
65
|
+
address(xml, buyer)
|
|
66
|
+
contact(xml, buyer)
|
|
67
|
+
xml.JST "2"
|
|
68
|
+
xml.GV "2"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A choice: NIP | KodUE + NrVatUE | KodKraju + NrID | BrakID.
|
|
73
|
+
def buyer_identifier(xml, party)
|
|
74
|
+
case party.identification
|
|
75
|
+
when "nip" then xml.NIP Nip.normalize!(party.nip, name: "buyer NIP")
|
|
76
|
+
when "eu"
|
|
77
|
+
xml.KodUE party.eu_code.to_s.strip.upcase
|
|
78
|
+
xml.NrVatUE party.vat_eu.to_s.strip
|
|
79
|
+
when "other"
|
|
80
|
+
xml.KodKraju party.country
|
|
81
|
+
xml.NrID party.id_number.to_s.strip
|
|
82
|
+
else xml.BrakID "1"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Without a country, a party identified by a NIP or by nothing is in Poland.
|
|
87
|
+
def address(xml, party)
|
|
88
|
+
xml.Adres do
|
|
89
|
+
xml.KodKraju blank?(party.country) ? "PL" : party.country
|
|
90
|
+
xml.AdresL1 party.address
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def contact(xml, party)
|
|
95
|
+
email = party.email.to_s.strip
|
|
96
|
+
phone = party.phone.to_s.strip
|
|
97
|
+
return if email.empty? && phone.empty?
|
|
98
|
+
|
|
99
|
+
xml.DaneKontaktowe do
|
|
100
|
+
xml.Email email unless email.empty?
|
|
101
|
+
xml.Telefon phone unless phone.empty?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def invoice_body(xml)
|
|
106
|
+
xml.Fa do
|
|
107
|
+
xml.KodWaluty currency
|
|
108
|
+
xml.P_1 iso(issue_date, "issue_date")
|
|
109
|
+
xml.P_1M place unless blank?(place)
|
|
110
|
+
xml.P_2 number
|
|
111
|
+
xml.P_6 iso(sale_date, "sale_date") if sale_date
|
|
112
|
+
summary(xml)
|
|
113
|
+
annotations_element(xml)
|
|
114
|
+
xml.RodzajFaktury invoice_type
|
|
115
|
+
correction(xml) if correction?
|
|
116
|
+
lines.each_with_index { |line, i| invoice_line(xml, line, i + 1) }
|
|
117
|
+
payment(xml)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def summary(xml)
|
|
122
|
+
totals = totals_by_rate
|
|
123
|
+
RATES.each do |rate, fields|
|
|
124
|
+
next unless totals[rate]
|
|
125
|
+
|
|
126
|
+
xml.public_send(fields[:net], amount(totals[rate][:net]))
|
|
127
|
+
xml.public_send(fields[:vat], amount(totals[rate][:vat])) if fields[:vat]
|
|
128
|
+
end
|
|
129
|
+
xml.P_15 amount(gross_total)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def annotations_element(xml)
|
|
133
|
+
flag = ->(key) { annotation?(key) ? "1" : "2" }
|
|
134
|
+
xml.Adnotacje do
|
|
135
|
+
%i[cash_method self_billing reverse_charge split_payment].each do |key|
|
|
136
|
+
xml.public_send(ANNOTATIONS[key], flag.call(key))
|
|
137
|
+
end
|
|
138
|
+
exemption(xml)
|
|
139
|
+
xml.NoweSrodkiTransportu { xml.P_22N "1" }
|
|
140
|
+
xml.P_23 flag.call(:triangular)
|
|
141
|
+
xml.PMarzy { xml.P_PMarzyN "1" }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def exemption(xml)
|
|
146
|
+
xml.Zwolnienie do
|
|
147
|
+
if exempt?
|
|
148
|
+
xml.P_19 "1"
|
|
149
|
+
xml.P_19A exemption_reason
|
|
150
|
+
else
|
|
151
|
+
xml.P_19N "1"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# `DaneFaKorygowanej` holds a choice: NrKSeF=1 + the KSeF number, or NrKSeFN=1 (issued outside KSeF).
|
|
157
|
+
def correction(xml)
|
|
158
|
+
xml.PrzyczynaKorekty correction_reason unless blank?(correction_reason)
|
|
159
|
+
xml.TypKorekty correction_type unless blank?(correction_type)
|
|
160
|
+
xml.DaneFaKorygowanej do
|
|
161
|
+
xml.DataWystFaKorygowanej iso(corrected_date, "corrected_date")
|
|
162
|
+
xml.NrFaKorygowanej corrected_number
|
|
163
|
+
if blank?(corrected_ksef_number)
|
|
164
|
+
xml.NrKSeFN "1"
|
|
165
|
+
else
|
|
166
|
+
xml.NrKSeF "1"
|
|
167
|
+
xml.NrKSeFFaKorygowanej corrected_ksef_number.strip
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def invoice_line(xml, line, index)
|
|
173
|
+
xml.FaWiersz do
|
|
174
|
+
xml.NrWierszaFa index
|
|
175
|
+
xml.P_7 line.name
|
|
176
|
+
optional(xml, Indeks: line.index, GTIN: line.gtin, PKWiU: line.pkwiu, CN: line.cn, P_8A: line.unit)
|
|
177
|
+
xml.P_8B Number.plain(line.number(:quantity))
|
|
178
|
+
xml.P_9A amount(line.number(:unit_price))
|
|
179
|
+
xml.P_10 amount(line.number(:discount)) if line.discount?
|
|
180
|
+
xml.P_11 amount(line.net)
|
|
181
|
+
xml.P_12 line.rate
|
|
182
|
+
xml.GTU line.gtu unless blank?(line.gtu)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Written when any payment field is given. `Zaplacono` + `DataZaplaty` is the first branch of an
|
|
187
|
+
# optional choice, ahead of the due date.
|
|
188
|
+
def payment(xml)
|
|
189
|
+
code = payment_code
|
|
190
|
+
return if code.nil? && !paid_flag && due_date.nil? && blank?(bank_account)
|
|
191
|
+
|
|
192
|
+
xml.Platnosc do
|
|
193
|
+
if paid_flag
|
|
194
|
+
xml.Zaplacono "1"
|
|
195
|
+
xml.DataZaplaty iso(paid_date, "paid_date")
|
|
196
|
+
end
|
|
197
|
+
xml.TerminPlatnosci { xml.Termin iso(due_date, "due_date") } if due_date
|
|
198
|
+
xml.FormaPlatnosci code if code
|
|
199
|
+
bank_account_element(xml)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def bank_account_element(xml)
|
|
204
|
+
return if blank?(bank_account)
|
|
205
|
+
|
|
206
|
+
xml.RachunekBankowy do
|
|
207
|
+
xml.NrRB bank_account.delete(" ")
|
|
208
|
+
xml.NazwaBanku bank_name unless blank?(bank_name)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def footer_element(xml)
|
|
213
|
+
registers = { KRS: krs, REGON: regon, BDO: bdo }.reject { |_, v| blank?(v) }
|
|
214
|
+
return if blank?(footer) && registers.empty?
|
|
215
|
+
|
|
216
|
+
xml.Stopka do
|
|
217
|
+
xml.Informacje { xml.StopkaFaktury footer } unless blank?(footer)
|
|
218
|
+
xml.Rejestry { optional(xml, **registers) } if registers.any?
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Emits each non-blank field in the given (schema) order.
|
|
223
|
+
def optional(xml, **fields)
|
|
224
|
+
fields.each { |tag, value| xml.public_send(tag, value.to_s.strip) unless blank?(value) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def blank?(value)
|
|
228
|
+
value.to_s.strip.empty?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Only a Date or "YYYY-MM-DD": a Time or a timestamp String is refused, as its calendar day depends
|
|
232
|
+
# on its zone.
|
|
233
|
+
def iso(date, name)
|
|
234
|
+
return nil if date.nil?
|
|
235
|
+
|
|
236
|
+
if date.is_a?(String)
|
|
237
|
+
raise Date::Error unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
238
|
+
|
|
239
|
+
return Date.iso8601(date).strftime("%Y-%m-%d")
|
|
240
|
+
end
|
|
241
|
+
unless date.is_a?(Date) && !date.is_a?(DateTime)
|
|
242
|
+
raise ConfigurationError, "#{name} must be a Date or \"YYYY-MM-DD\", not a #{date.class}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
date.strftime("%Y-%m-%d")
|
|
246
|
+
rescue Date::Error
|
|
247
|
+
raise ConfigurationError, "#{name} is not a valid date (\"YYYY-MM-DD\"): #{date.inspect}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def amount(value)
|
|
251
|
+
Number.amount(value)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|