einvoicing 0.2.0 → 0.3.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.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+
6
+ module Einvoicing
7
+ # A single line on an invoice.
8
+ #
9
+ # @example
10
+ # Einvoicing::LineItem.new(
11
+ # description: "Software consulting",
12
+ # quantity: 5,
13
+ # unit_price: 150.00,
14
+ # vat_rate: 0.20
15
+ # )
16
+ LineItem = Data.define(:description, :quantity, :unit_price, :vat_rate, :unit, :category) do
17
+ def initialize(description:, quantity:, unit_price:, vat_rate: 0.20, unit: "C62", category: nil)
18
+ super(
19
+ description: description,
20
+ quantity: BigDecimal(quantity.to_s),
21
+ unit_price: BigDecimal(unit_price.to_s),
22
+ vat_rate: vat_rate,
23
+ unit: unit,
24
+ category: category
25
+ )
26
+ end
27
+
28
+ # Net line total (excluding VAT).
29
+ def net_amount
30
+ (quantity * unit_price).round(2, :half_up)
31
+ end
32
+
33
+ # VAT amount for this line.
34
+ def vat_amount
35
+ (net_amount * BigDecimal(vat_rate.to_s)).round(2, :half_up)
36
+ end
37
+
38
+ # Gross line total (including VAT).
39
+ def gross_amount
40
+ (net_amount + vat_amount).round(2, :half_up)
41
+ end
42
+
43
+ def vat_rate_percent
44
+ return BigDecimal("0") if category == :reverse_charge
45
+
46
+ (BigDecimal(vat_rate.to_s) * 100).round(2)
47
+ end
48
+
49
+ # CII/UBL tax category code — delegates to shared Tax logic.
50
+ def tax_category_code
51
+ Tax.category_code_for(rate: vat_rate, category: category)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ # Represents a seller or buyer on an invoice.
5
+ #
6
+ # @example
7
+ # Einvoicing::Party.new(
8
+ # name: "Acme SAS",
9
+ # street: "1 rue de la Paix",
10
+ # city: "Paris",
11
+ # postal_code: "75001",
12
+ # country_code: "FR",
13
+ # siren: "123456789",
14
+ # vat_number: "FR12123456789"
15
+ # )
16
+ Party = Data.define(:name, :street, :city, :postal_code, :country_code,
17
+ :siren, :siret, :vat_number, :email) do
18
+ def initialize(name:, street: nil, city: nil, postal_code: nil,
19
+ country_code: "FR", siren: nil, siret: nil,
20
+ vat_number: nil, email: nil)
21
+ super
22
+ end
23
+
24
+ # The 9-digit SIREN derived from SIRET if siren not provided directly.
25
+ def siren_number
26
+ siren || (siret && siret[0, 9])
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module Einvoicing
7
+ module PPF
8
+ class Client
9
+ SANDBOX_OAUTH_URL = "https://sandbox-oauth.piste.gouv.fr"
10
+ SANDBOX_API_URL = "https://sandbox-api.piste.gouv.fr/cpro/factures"
11
+ PROD_OAUTH_URL = "https://oauth.piste.gouv.fr"
12
+ PROD_API_URL = "https://api.piste.gouv.fr/cpro/factures"
13
+
14
+ attr_reader :sandbox
15
+
16
+ def initialize(client_id:, client_secret:, sandbox: true)
17
+ @client_id = client_id
18
+ @client_secret = client_secret
19
+ @sandbox = sandbox
20
+ @token = nil
21
+ @token_expires_at = nil
22
+ end
23
+
24
+ # Returns current access token, refreshing if expired.
25
+ def access_token
26
+ refresh_token! if token_expired?
27
+ @token
28
+ end
29
+
30
+ # POST /v1/rechercher/structure — find structure by SIRET, returns idStructureCPP.
31
+ def find_structure(siret:)
32
+ post("/v1/rechercher/structure", { siret: siret })
33
+ end
34
+
35
+ # POST /v1/consulter/structure — get mandatory params (engagement, service codes).
36
+ def get_structure(id_structure_cpp:)
37
+ post("/v1/consulter/structure", { idStructureCPP: id_structure_cpp })
38
+ end
39
+
40
+ # POST /v1/rechercher/service/structure — list active services for a structure.
41
+ def list_services(id_structure_cpp:, page: 1)
42
+ post("/v1/rechercher/service/structure", {
43
+ idStructureCPP: id_structure_cpp,
44
+ pageResultat: page
45
+ })
46
+ end
47
+
48
+ # POST /v1/soumettre/factures — submit an invoice.
49
+ # facture_hash: Hash matching the Chorus Pro invoice schema.
50
+ def submit_invoice(facture_hash)
51
+ post("/v1/soumettre/factures", facture_hash)
52
+ end
53
+
54
+ private
55
+
56
+ def base_url
57
+ sandbox ? SANDBOX_API_URL : PROD_API_URL
58
+ end
59
+
60
+ def oauth_url
61
+ sandbox ? SANDBOX_OAUTH_URL : PROD_OAUTH_URL
62
+ end
63
+
64
+ def token_expired?
65
+ @token.nil? || @token_expires_at.nil? || Time.now >= @token_expires_at
66
+ end
67
+
68
+ def refresh_token!
69
+ uri = URI("#{oauth_url}/api/oauth/token")
70
+ req = Net::HTTP::Post.new(uri)
71
+ req.set_form_data(
72
+ grant_type: "client_credentials",
73
+ client_id: @client_id,
74
+ client_secret: @client_secret,
75
+ scope: "openid"
76
+ )
77
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
78
+ raise AuthenticationError, "OAuth token request failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)
79
+
80
+ data = JSON.parse(res.body)
81
+ @token = data["access_token"]
82
+ @token_expires_at = Time.now + data.fetch("expires_in", 3600).to_i - 60
83
+ end
84
+
85
+ def post(path, body)
86
+ uri = URI("#{base_url}#{path}")
87
+ req = Net::HTTP::Post.new(uri)
88
+ req["Authorization"] = "Bearer #{access_token}"
89
+ req["Content-Type"] = "application/json;charset=UTF-8"
90
+ req["Accept"] = "application/json"
91
+ req.body = body.to_json
92
+ res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
93
+ handle_response(res)
94
+ end
95
+
96
+ def handle_response(res)
97
+ body = begin
98
+ JSON.parse(res.body)
99
+ rescue StandardError
100
+ res.body
101
+ end
102
+ case res
103
+ when Net::HTTPSuccess
104
+ body
105
+ when Net::HTTPUnauthorized
106
+ raise AuthenticationError, "Unauthorized: #{body}"
107
+ when Net::HTTPForbidden
108
+ raise AuthorizationError, "Forbidden: #{body}"
109
+ when Net::HTTPNotFound
110
+ raise NotFoundError, "Not found: #{body}"
111
+ else
112
+ raise APIError, "API error #{res.code}: #{body}"
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module PPF
5
+ Error = Class.new(StandardError)
6
+ AuthenticationError = Class.new(Error)
7
+ AuthorizationError = Class.new(Error)
8
+ NotFoundError = Class.new(Error)
9
+ APIError = Class.new(Error)
10
+ ValidationError = Class.new(Error)
11
+ end
12
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module PPF
5
+ class InvoiceAdapter
6
+ # Converts an Einvoicing::Invoice to a Chorus Pro SoumettreFacture payload.
7
+ #
8
+ # @param invoice [Einvoicing::Invoice]
9
+ # @param id_structure_cpp [Integer] from find_structure()
10
+ # @param code_service [String, nil] from list_services() — optional
11
+ # @param numero_engagement [String, nil] buyer PO/engagement number — optional for B2B
12
+ def self.to_chorus_payload(invoice, id_structure_cpp:, code_service: nil, numero_engagement: nil)
13
+ {
14
+ idStructureCPP: id_structure_cpp,
15
+ codeService: code_service,
16
+ numeroEngagement: numero_engagement,
17
+ cadreFacturation: {
18
+ codeCadreFacturation: "FACTURE_FOURNISSEUR",
19
+ codeServiceValideur: nil
20
+ },
21
+ identifiantFactureFournisseur: invoice.invoice_number,
22
+ dateFacture: invoice.issue_date.strftime("%Y-%m-%dT00:00:00.000+01:00"),
23
+ dateEcheancePaiement: invoice.due_date&.strftime("%Y-%m-%dT00:00:00.000+01:00"),
24
+ montantHT: invoice.net_total.to_f,
25
+ montantTVA: invoice.tax_total.to_f,
26
+ montantTTC: invoice.gross_total.to_f,
27
+ devise: invoice.currency || "EUR",
28
+ siretFournisseur: invoice.seller.siret,
29
+ siretDestinataire: invoice.buyer.siret,
30
+ typeFacture: "FACTURE",
31
+ lignesPoste: invoice.lines.map.with_index(1) { |line, i| line_to_chorus(line, i) },
32
+ modePaiement: chorus_payment_mode(invoice)
33
+ }.compact
34
+ end
35
+
36
+ private_class_method def self.line_to_chorus(line, index)
37
+ {
38
+ numeroLigne: index,
39
+ designation: line.description,
40
+ quantite: line.quantity.to_f,
41
+ unite: "U",
42
+ prixUnitaireHT: line.unit_price.to_f,
43
+ montantHT: line.net_amount.to_f,
44
+ tauxTVA: (line.vat_rate * 100).to_f,
45
+ montantTVA: (line.net_amount * line.vat_rate).to_f.round(2)
46
+ }
47
+ end
48
+
49
+ private_class_method def self.chorus_payment_mode(invoice)
50
+ return nil unless invoice.respond_to?(:payment_means_code) && invoice.payment_means_code
51
+
52
+ code_map = { 30 => "VIREMENT", 42 => "VIREMENT", 58 => "VIREMENT" }
53
+ mode = code_map[invoice.payment_means_code] || "VIREMENT"
54
+ result = { modePaiement: mode }
55
+ result[:iban] = invoice.iban if invoice.respond_to?(:iban) && invoice.iban
56
+ result[:bic] = invoice.bic if invoice.respond_to?(:bic) && invoice.bic
57
+ result
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module PPF
5
+ class Submitter
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ # Submit an invoice to Chorus Pro / PPF.
11
+ # Returns the submission result hash from the API.
12
+ #
13
+ # @param invoice [Einvoicing::Invoice]
14
+ # @param code_service [String, nil] optional service code from list_services
15
+ # @param numero_engagement [String, nil] optional engagement number
16
+ def submit(invoice, code_service: nil, numero_engagement: nil)
17
+ structure = @client.find_structure(siret: invoice.buyer.siret)
18
+ id_structure = structure["idStructureCPP"] || structure.dig("parametres", "idStructureCPP")
19
+ raise ValidationError, "Buyer SIRET #{invoice.buyer.siret} not found in Chorus Pro" unless id_structure
20
+
21
+ payload = InvoiceAdapter.to_chorus_payload(
22
+ invoice,
23
+ id_structure_cpp: id_structure,
24
+ code_service: code_service,
25
+ numero_engagement: numero_engagement
26
+ )
27
+
28
+ @client.submit_invoice(payload)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ppf/errors"
4
+ require_relative "ppf/client"
5
+ require_relative "ppf/invoice_adapter"
6
+ require_relative "ppf/submitter"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Redirect: Invoiceable has moved to lib/einvoicing/invoiceable.rb.
4
+ require_relative "../invoiceable"
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module Rails
5
+ # Rails engine entry point. Automatically extends ActiveRecord::Base with
6
+ # the Invoiceable concern when loaded inside a Rails application.
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Einvoicing
9
+
10
+ initializer "einvoicing.i18n" do
11
+ config.i18n.load_path += Dir[File.expand_path("../../../config/locales/*.yml", __dir__)]
12
+ end
13
+
14
+ initializer "einvoicing.active_record" do
15
+ ActiveSupport.on_load(:active_record) do
16
+ # Models opt-in via `include Einvoicing::Invoiceable`
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+
6
+ module Einvoicing
7
+ # VAT breakdown entry for a single tax rate.
8
+ Tax = Data.define(:rate, :taxable_amount, :tax_amount, :category) do
9
+ # @param rate [Numeric] e.g. 0.20 for 20% VAT; 0 for zero-rated or reverse charge
10
+ # @param taxable_amount [Numeric] net amount subject to this rate
11
+ # @param tax_amount [Numeric] VAT amount for this rate
12
+ # @param category [Symbol, nil] nil for standard/zero, :reverse_charge for AE
13
+ def initialize(rate:, taxable_amount:, tax_amount:, category: nil)
14
+ raise ArgumentError, "rate must be >= 0, got #{rate}" if rate.to_f.negative?
15
+
16
+ super
17
+ end
18
+
19
+ # Shared lookup used by both Tax and LineItem.
20
+ def self.category_code_for(rate:, category: nil)
21
+ if rate.to_f == 0.0
22
+ category == :reverse_charge ? "AE" : "Z"
23
+ else
24
+ "S"
25
+ end
26
+ end
27
+
28
+ def category_code
29
+ Tax.category_code_for(rate: rate, category: category)
30
+ end
31
+
32
+ def rate_percent
33
+ return BigDecimal("0") if category == :reverse_charge
34
+
35
+ (BigDecimal(rate.to_s) * 100).round(2)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module Validators
5
+ # Shared validation helpers for country-specific validators.
6
+ # Each validator exposes a `.validate(invoice)` class method that returns
7
+ # an array of error hashes (empty = valid).
8
+ # Each error hash has the shape:
9
+ # { field: Symbol, error: Symbol, message: String }
10
+ module Base
11
+ def self.included(base)
12
+ base.extend(ClassMethods)
13
+ end
14
+
15
+ module ClassMethods
16
+ # Validate an invoice and raise if any errors exist.
17
+ def validate!(invoice)
18
+ errors = validate(invoice)
19
+ raise ValidationError, errors.map { |e| e[:message] }.join("; ") unless errors.empty?
20
+
21
+ true
22
+ end
23
+ end
24
+
25
+ # Luhn algorithm for SIREN (9 digits) and SIRET (14 digits).
26
+ # Returns true if the number passes the Luhn check.
27
+ def self.luhn_valid?(number_string)
28
+ digits = number_string.chars.map(&:to_i)
29
+ sum = 0
30
+ digits.reverse.each_with_index do |d, i|
31
+ d = i.odd? ? d * 2 : d
32
+ d -= 9 if d > 9
33
+ sum += d
34
+ end
35
+ (sum % 10).zero?
36
+ end
37
+
38
+ # Presence check — returns an error hash or nil.
39
+ def self.presence(value, field, message, error: :blank)
40
+ { field: field, error: error, message: message } if value.nil? || value.to_s.strip.empty?
41
+ end
42
+
43
+ # Format check via regex — returns an error hash or nil.
44
+ def self.format(value, field, pattern, message)
45
+ { field: field, error: :invalid, message: message } unless value.to_s.match?(pattern)
46
+ end
47
+ end
48
+
49
+ # Raised when `.validate!` finds errors.
50
+ class ValidationError < StandardError; end
51
+ end
52
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module Validators
5
+ # French invoice validator.
6
+ # Checks SIREN, SIRET, TVA (VAT) number format and Luhn checksum, plus
7
+ # mandatory invoice fields for French B2B compliance.
8
+ #
9
+ # @example
10
+ # errors = Einvoicing::Validators::FR.validate(invoice)
11
+ # errors.empty? # => true if valid
12
+ #
13
+ # Einvoicing::Validators::FR.validate!(invoice) # raises on failure
14
+ module FR
15
+ SIREN_RE = /\A\d{9}\z/
16
+ SIRET_RE = /\A\d{14}\z/
17
+ # FR VAT: "FR" + 2 alphanumeric chars + 9-digit SIREN
18
+ VAT_RE = /\AFR[A-Z0-9]{2}\d{9}\z/
19
+ INV_NUM_RE = /\A[\w\-\/]{1,35}\z/
20
+ # IBAN: country code (2 alpha) + 2 check digits + BBAN (11-30 alphanumeric) = 15-34 total
21
+ IBAN_RE = /\A[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}\z/
22
+ # BIC: 4 alpha (institution) + 2 alpha (country) + 2 alphanumeric (location) + optional 3 alphanumeric (branch)
23
+ BIC_RE = /\A[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?\z/
24
+
25
+ # @param invoice [Einvoicing::Invoice]
26
+ # @return [Array<Hash>] list of error hashes ({ field:, error:, message: }); empty if valid
27
+ def self.validate(invoice)
28
+ [
29
+ *validate_invoice_fields(invoice),
30
+ *validate_party(invoice.seller, :seller),
31
+ *validate_party(invoice.buyer, :buyer),
32
+ *validate_lines(invoice.lines)
33
+ ]
34
+ end
35
+
36
+ # @raise [Einvoicing::Validators::ValidationError] if invalid
37
+ def self.validate!(invoice)
38
+ errors = validate(invoice)
39
+ raise ValidationError, errors.map { |e| e[:message] }.join("; ") unless errors.empty?
40
+
41
+ true
42
+ end
43
+
44
+ # Validate a single SIREN number.
45
+ # @param siren [String]
46
+ # @return [Boolean]
47
+ def self.valid_siren?(siren)
48
+ return false unless siren.to_s.match?(SIREN_RE)
49
+
50
+ Base.luhn_valid?(siren.to_s)
51
+ end
52
+
53
+ # Validate a single SIRET number.
54
+ # @param siret [String]
55
+ # @return [Boolean]
56
+ def self.valid_siret?(siret)
57
+ return false unless siret.to_s.match?(SIRET_RE)
58
+
59
+ Base.luhn_valid?(siret.to_s)
60
+ end
61
+
62
+ # Validate a French VAT number format.
63
+ # @param vat [String] e.g. "FR12123456789"
64
+ # @return [Boolean]
65
+ def self.valid_vat_number?(vat)
66
+ vat.to_s.match?(VAT_RE)
67
+ end
68
+
69
+ # Validate an invoice number format (alphanumeric, dashes, slashes, 1-35 chars).
70
+ # @param number [String]
71
+ # @return [Boolean]
72
+ def self.valid_invoice_number?(number)
73
+ number.to_s.match?(INV_NUM_RE)
74
+ end
75
+
76
+ # Validate an IBAN (ISO 13616 format + mod-97 checksum).
77
+ # @param iban [String]
78
+ # @return [Boolean]
79
+ def self.valid_iban?(iban)
80
+ str = iban.to_s.gsub(/\s/, "").upcase
81
+ return false unless str.match?(IBAN_RE)
82
+
83
+ # Move first 4 chars to end, replace each letter with its numeric value (A=10..Z=35)
84
+ rearranged = str[4..] + str[0..3]
85
+ numeric = rearranged.chars.map { |c| c =~ /[A-Z]/ ? (c.ord - 55).to_s : c }.join
86
+ numeric.to_i % 97 == 1
87
+ end
88
+
89
+ # Validate a BIC (ISO 9362) — 8 or 11 chars.
90
+ # @param bic [String]
91
+ # @return [Boolean]
92
+ def self.valid_bic?(bic)
93
+ bic.to_s.match?(BIC_RE)
94
+ end
95
+
96
+ # -- Private helpers ---------------------------------------------------
97
+
98
+ def self.validate_invoice_fields(invoice)
99
+ errors = [
100
+ Base.presence(invoice.invoice_number, :invoice_number,
101
+ Einvoicing::I18n.t("errors.invoice.number_missing"),
102
+ error: :number_missing),
103
+ Base.presence(invoice.issue_date, :issue_date,
104
+ Einvoicing::I18n.t("errors.invoice.issue_date_missing"),
105
+ error: :issue_date_missing),
106
+ Base.presence(invoice.currency, :currency,
107
+ Einvoicing::I18n.t("errors.invoice.currency_missing"),
108
+ error: :currency_missing)
109
+ ].compact
110
+ unless valid_invoice_number?(invoice.invoice_number.to_s)
111
+ errors << { field: :invoice_number, error: :number_invalid,
112
+ message: Einvoicing::I18n.t("errors.invoice.number_invalid") }
113
+ end
114
+ if invoice.document_type == :credit_note &&
115
+ invoice.original_invoice_number.to_s.strip.empty?
116
+ errors << { field: :original_invoice_number, error: :original_invoice_number_missing,
117
+ message: Einvoicing::I18n.t("errors.invoice.original_invoice_number_missing") }
118
+ end
119
+ if invoice.iban && !valid_iban?(invoice.iban)
120
+ errors << { field: :iban, error: :iban_invalid,
121
+ message: Einvoicing::I18n.t("errors.invoice.iban_invalid") }
122
+ end
123
+ if invoice.bic && !valid_bic?(invoice.bic)
124
+ errors << { field: :bic, error: :bic_invalid,
125
+ message: Einvoicing::I18n.t("errors.invoice.bic_invalid") }
126
+ end
127
+ errors
128
+ end
129
+ private_class_method :validate_invoice_fields
130
+
131
+ def self.validate_party(party, role)
132
+ name_field = :"#{role}_name"
133
+ errors = [
134
+ Base.presence(party&.name, name_field,
135
+ Einvoicing::I18n.t("errors.#{role}.name_missing"),
136
+ error: :name_missing)
137
+ ].compact
138
+ return errors if party.nil?
139
+
140
+ siren = party.siren_number
141
+ if siren && !valid_siren?(siren)
142
+ errors << { field: :"#{role}_siren", error: :siren_invalid,
143
+ message: Einvoicing::I18n.t("errors.#{role}.siren_invalid") }
144
+ end
145
+
146
+ if party.siret && !valid_siret?(party.siret)
147
+ errors << { field: :"#{role}_siret", error: :siret_invalid,
148
+ message: Einvoicing::I18n.t("errors.#{role}.siret_invalid") }
149
+ end
150
+
151
+ if party.vat_number && !valid_vat_number?(party.vat_number)
152
+ errors << { field: :"#{role}_vat_number", error: :vat_number_invalid,
153
+ message: Einvoicing::I18n.t("errors.#{role}.vat_number_invalid") }
154
+ end
155
+
156
+ errors
157
+ end
158
+ private_class_method :validate_party
159
+
160
+ def self.validate_lines(lines)
161
+ if lines.nil? || lines.empty?
162
+ return [{ field: :lines, error: :lines_empty,
163
+ message: Einvoicing::I18n.t("errors.invoice.lines_empty") }]
164
+ end
165
+
166
+ lines.each_with_index.flat_map do |line, idx|
167
+ n = idx + 1
168
+ [
169
+ (if line.description.to_s.strip.empty?
170
+ { field: :"line_#{n}_description", error: :description_missing,
171
+ message: Einvoicing::I18n.t("errors.line.description_missing", index: n) }
172
+ end),
173
+ (unless line.quantity.to_f.positive?
174
+ { field: :"line_#{n}_quantity", error: :quantity_invalid,
175
+ message: Einvoicing::I18n.t("errors.line.quantity_invalid", index: n) }
176
+ end),
177
+ (if line.unit_price.to_f.negative?
178
+ { field: :"line_#{n}_unit_price", error: :unit_price_invalid,
179
+ message: Einvoicing::I18n.t("errors.line.unit_price_invalid", index: n) }
180
+ end),
181
+ (unless [0.0, 0.055, 0.10, 0.20].include?(line.vat_rate.to_f.round(3))
182
+ { field: :"line_#{n}_vat_rate", error: :vat_rate_invalid,
183
+ message: Einvoicing::I18n.t("errors.line.vat_rate_invalid", index: n) }
184
+ end)
185
+ ]
186
+ end.compact
187
+ end
188
+ private_class_method :validate_lines
189
+ end
190
+ end
191
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Einvoicing
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end