einvoicing 0.7.1 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a27d36b05ee79942e8828c53055b9b3088bec50a526d1277dff05aa7b447e405
4
- data.tar.gz: bf8f072d6f8a3e15b5f74c06117f3e8def1fd1eb81173dcb2edcaf1bced32f02
3
+ metadata.gz: d547dfe66db0d1629517aae3053ac762eb5f67757f79cea0c143c1c64b8a45dc
4
+ data.tar.gz: 2c30feb03f0679e0a98f45f96f42df87552316a4aaa64070d1a62904c7f788b8
5
5
  SHA512:
6
- metadata.gz: 235d066a6f7d45ed40d10cbd871a560008a33e3bd5c2791e66bafd5f5649a62da0c84f31b7df854e06bf78e8c9514f948f23c88a5043f27bc8ed7f36b46ca11e
7
- data.tar.gz: 357abed47d361906c413171f66ba6d66963b275e4149d141d94886822dca14958ca75d0e164ed3d523b293482454ad3baee49347702aec29b8e00bb97d78099f
6
+ metadata.gz: a1869726902c83f9942dcc7a1eaa667e9ef6acca8c142310b2b7cec2f02dd8221756db936753ae91a5c7c5512900edeeddd56bef76ad323f9164313861259fbe
7
+ data.tar.gz: cd041966fd406bdcefbedec61ff00af79eb67a62a1ef7cce48fd0c2098e5c55251bffe16da93906d278e1993458813cbf56cabfd6af4a75e1f66d898b4e7cb6a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-07-25
11
+
12
+ ### Added
13
+ - Document-level allowances (BG-20) and charges (BG-21) via `Einvoicing::AllowanceCharge`, passed to `Invoice` as `allowances:` and `charges:` — emitted as `ram:SpecifiedTradeAllowanceCharge` in CII and `cac:AllowanceCharge` in UBL, with reason (BT-97/BT-104), reason code (BT-98/BT-105), base amount (BT-93/BT-100) and percentage (BT-94/BT-101)
14
+ - `Invoice#line_total` (BT-106), `#allowance_total` (BT-107) and `#charge_total` (BT-108); `#net_total` is now BT-109 (`line_total − allowance_total + charge_total`, BR-CO-13) and the VAT breakdown adjusts each category's taxable base and VAT accordingly (BR-CO-10 to BR-CO-12)
15
+ - `einvoicing_allowances` / `einvoicing_charges` hooks on `Einvoicing::Invoiceable` (both default to `[]`)
16
+ - FR validator checks on allowances and charges: non-negative amount, reason or reason code required (BR-33 / BR-38), known French VAT rate
17
+
18
+ ### Fixed
19
+ - `Invoice#with` recomputes the tax breakdown when `lines`, `allowances` or `charges` change, instead of carrying the stale one over to the copy — pass `tax_breakdown:` explicitly to keep a custom breakdown
20
+
10
21
  ## [0.7.1] - 2026-07-16
11
22
 
12
23
  ### Fixed
@@ -96,6 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
96
107
  - Zero runtime dependencies beyond hexapdf (stdlib-only XML generation via internal builder)
97
108
  - RSpec test suite
98
109
 
99
- [Unreleased]: https://github.com/sxnlabs/einvoicing/compare/v0.7.1...HEAD
110
+ [Unreleased]: https://github.com/sxnlabs/einvoicing/compare/v0.8.0...HEAD
111
+ [0.8.0]: https://github.com/sxnlabs/einvoicing/compare/v0.7.1...v0.8.0
100
112
  [0.7.1]: https://github.com/sxnlabs/einvoicing/compare/v0.7.0...v0.7.1
101
113
  [0.1.0]: https://github.com/sxnlabs/einvoicing/releases/tag/v0.1.0
data/README.md CHANGED
@@ -254,6 +254,39 @@ end
254
254
 
255
255
  A custom validator is any module that responds to `.validate(invoice)` and returns `Array<Hash>`.
256
256
 
257
+ ## Document-Level Allowances and Charges
258
+
259
+ A global discount (BG-20) or a global charge such as shipping (BG-21) is not a line item — it applies to the whole document, at a given VAT rate:
260
+
261
+ ```ruby
262
+ invoice = Einvoicing::Invoice.new(
263
+ # ... other fields ...
264
+ lines: lines,
265
+ allowances: [
266
+ Einvoicing::AllowanceCharge.new(
267
+ amount: BigDecimal("100.00"), # BT-92 — always positive
268
+ vat_rate: 0.20, # BT-96 — the rate it applies to
269
+ reason: "Remise commerciale", # BT-97
270
+ reason_code: "95", # BT-98 (UNCL5189)
271
+ base_amount: BigDecimal("1000.00"), # BT-93 (optional)
272
+ percentage: 10 # BT-94 (optional)
273
+ )
274
+ ],
275
+ charges: [
276
+ Einvoicing::AllowanceCharge.new(amount: BigDecimal("50.00"), vat_rate: 0.20, reason: "Frais de port")
277
+ ]
278
+ )
279
+
280
+ invoice.line_total # BT-106 — sum of line net amounts => 1000.00
281
+ invoice.allowance_total # BT-107 => 100.00
282
+ invoice.charge_total # BT-108 => 50.00
283
+ invoice.net_total # BT-109 — lines − allowances + charges => 950.00
284
+ invoice.tax_total # BT-110 — VAT on the adjusted base => 190.00
285
+ invoice.gross_total # BT-112 => 1140.00
286
+ ```
287
+
288
+ Each allowance and charge adjusts the taxable base and the VAT of its own category in the breakdown (EN 16931 BR-CO-10 to BR-CO-13). EN 16931 requires a reason or a reason code on each one (BR-33 / BR-38), and the FR validator enforces it.
289
+
257
290
  ## Payment Means
258
291
 
259
292
  Add IBAN, BIC, and UNCL4461 payment type code to the invoice. Both CII and UBL generators emit the appropriate elements automatically.
@@ -27,6 +27,14 @@ en:
27
27
  quantity_invalid: "Line %{index}: quantity must be positive"
28
28
  unit_price_invalid: "Line %{index}: unit price must be non-negative"
29
29
  vat_rate_invalid: "Line %{index}: VAT rate must be a known French rate (0%%, 5.5%%, 10%%, 20%%)"
30
+ allowance:
31
+ amount_invalid: "Allowance %{index}: amount must be non-negative"
32
+ reason_missing: "Allowance %{index}: a reason or a reason code is required"
33
+ vat_rate_invalid: "Allowance %{index}: VAT rate must be a known French rate (0%%, 5.5%%, 10%%, 20%%)"
34
+ charge:
35
+ amount_invalid: "Charge %{index}: amount must be non-negative"
36
+ reason_missing: "Charge %{index}: a reason or a reason code is required"
37
+ vat_rate_invalid: "Charge %{index}: VAT rate must be a known French rate (0%%, 5.5%%, 10%%, 20%%)"
30
38
  formats:
31
39
  unknown_format: "Unknown format: %{fmt}. Use :cii or :ubl"
32
40
  unknown_market: "Unknown market: %{market}. Use :fr"
@@ -27,6 +27,14 @@ fr:
27
27
  quantity_invalid: "Ligne %{index} : la quantité doit être positive"
28
28
  unit_price_invalid: "Ligne %{index} : le prix unitaire doit être non négatif"
29
29
  vat_rate_invalid: "Ligne %{index} : le taux de TVA doit être un taux français standard (0%%, 5,5%%, 10%%, 20%%)"
30
+ allowance:
31
+ amount_invalid: "Remise %{index} : le montant doit être non négatif"
32
+ reason_missing: "Remise %{index} : un motif ou un code motif est requis"
33
+ vat_rate_invalid: "Remise %{index} : le taux de TVA doit être un taux français standard (0%%, 5,5%%, 10%%, 20%%)"
34
+ charge:
35
+ amount_invalid: "Frais %{index} : le montant doit être non négatif"
36
+ reason_missing: "Frais %{index} : un motif ou un code motif est requis"
37
+ vat_rate_invalid: "Frais %{index} : le taux de TVA doit être un taux français standard (0%%, 5,5%%, 10%%, 20%%)"
30
38
  formats:
31
39
  unknown_format: "Format inconnu : %{fmt}. Utilisez :cii ou :ubl"
32
40
  unknown_market: "Marché inconnu : %{market}. Utilisez :fr"
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+
6
+ module Einvoicing
7
+ # A document-level allowance (BG-20) or charge (BG-21).
8
+ #
9
+ # The same shape covers both: whether it is deducted or added is decided by
10
+ # the array it sits in on the invoice (`allowances:` or `charges:`), so the
11
+ # amount itself is always expressed as a positive value.
12
+ #
13
+ # @example A 10% commercial discount on a 20% VAT base
14
+ # Einvoicing::AllowanceCharge.new(
15
+ # amount: BigDecimal("100.00"),
16
+ # vat_rate: 0.20,
17
+ # reason: "Remise commerciale",
18
+ # reason_code: "95",
19
+ # base_amount: BigDecimal("1000.00"),
20
+ # percentage: 10
21
+ # )
22
+ AllowanceCharge = Data.define(:amount, :vat_rate, :category, :reason,
23
+ :reason_code, :base_amount, :percentage) do
24
+ # @param amount [Numeric] BT-92 / BT-99 — always positive
25
+ # @param vat_rate [Numeric] BT-96 / BT-103 — the rate the amount applies to
26
+ # @param category [Symbol, nil] nil for standard/zero, :reverse_charge for AE
27
+ # @param reason [String, nil] BT-97 / BT-104
28
+ # @param reason_code [String, nil] BT-98 / BT-105 (UNCL5189 / UNCL7161)
29
+ # @param base_amount [Numeric, nil] BT-93 / BT-100
30
+ # @param percentage [Numeric, nil] BT-94 / BT-101
31
+ def initialize(amount:, vat_rate: 0.20, category: nil, reason: nil,
32
+ reason_code: nil, base_amount: nil, percentage: nil)
33
+ super(
34
+ amount: BigDecimal(amount.to_s),
35
+ vat_rate: vat_rate,
36
+ category: category,
37
+ reason: reason,
38
+ reason_code: reason_code,
39
+ base_amount: base_amount.nil? ? nil : BigDecimal(base_amount.to_s),
40
+ percentage: percentage
41
+ )
42
+ end
43
+
44
+ # VAT carried by this allowance/charge at its own rate.
45
+ def vat_amount
46
+ (amount * BigDecimal(vat_rate.to_s)).round(2, :half_up)
47
+ end
48
+
49
+ def vat_rate_percent
50
+ return BigDecimal("0") if category == :reverse_charge
51
+
52
+ (BigDecimal(vat_rate.to_s) * 100).round(2)
53
+ end
54
+
55
+ # CII/UBL tax category code — delegates to shared Tax logic.
56
+ def tax_category_code
57
+ Tax.category_code_for(rate: vat_rate, category: category)
58
+ end
59
+ end
60
+ end
@@ -182,6 +182,9 @@ module Einvoicing
182
182
  end
183
183
  end
184
184
 
185
+ invoice.allowances.each { |allowance| trade_allowance_charge(b, allowance, charge: false) }
186
+ invoice.charges.each { |charge| trade_allowance_charge(b, charge, charge: true) }
187
+
185
188
  if invoice.due_date
186
189
  b.tag("ram:SpecifiedTradePaymentTerms") do
187
190
  b.tag("ram:DueDateDateTime") do
@@ -191,7 +194,9 @@ module Einvoicing
191
194
  end
192
195
 
193
196
  b.tag("ram:SpecifiedTradeSettlementHeaderMonetarySummation") do
194
- b.text("ram:LineTotalAmount", format_amount(invoice.net_total))
197
+ b.text("ram:LineTotalAmount", format_amount(invoice.line_total))
198
+ b.text("ram:ChargeTotalAmount", format_amount(invoice.charge_total)) if invoice.charge_total.positive?
199
+ b.text("ram:AllowanceTotalAmount", format_amount(invoice.allowance_total)) if invoice.allowance_total.positive?
195
200
  b.text("ram:TaxBasisTotalAmount", format_amount(invoice.net_total))
196
201
  b.text("ram:TaxTotalAmount", format_amount(invoice.tax_total),
197
202
  "currencyID" => invoice.currency)
@@ -206,6 +211,27 @@ module Einvoicing
206
211
  end
207
212
  private_class_method :header_trade_settlement
208
213
 
214
+ # BG-20 (allowance) / BG-21 (charge) at document level. The element order
215
+ # follows the Factur-X EN16931 profile sequence.
216
+ def self.trade_allowance_charge(b, item, charge:)
217
+ b.tag("ram:SpecifiedTradeAllowanceCharge") do
218
+ b.tag("ram:ChargeIndicator") do
219
+ b.text("udt:Indicator", charge.to_s)
220
+ end
221
+ b.text("ram:CalculationPercent", format_amount(item.percentage)) if item.percentage
222
+ b.text("ram:BasisAmount", format_amount(item.base_amount)) if item.base_amount
223
+ b.text("ram:ActualAmount", format_amount(item.amount))
224
+ b.text("ram:ReasonCode", item.reason_code) if item.reason_code
225
+ b.text("ram:Reason", item.reason) if item.reason
226
+ b.tag("ram:CategoryTradeTax") do
227
+ b.text("ram:TypeCode", "VAT")
228
+ b.text("ram:CategoryCode", item.tax_category_code)
229
+ b.text("ram:RateApplicablePercent", format_amount(item.vat_rate_percent))
230
+ end
231
+ end
232
+ end
233
+ private_class_method :trade_allowance_charge
234
+
209
235
  def self.preceding_invoice_reference(b, invoice)
210
236
  b.tag("ram:InvoiceReferencedDocument") do
211
237
  b.text("ram:IssuerAssignedID", invoice.original_invoice_number)
@@ -32,6 +32,8 @@ module Einvoicing
32
32
  customer_party(b, invoice.buyer)
33
33
  billing_reference(b, invoice) if credit_note && invoice.original_invoice_number
34
34
  payment_means(b, invoice) if invoice.payment_means_code
35
+ invoice.allowances.each { |a| allowance_charge(b, a, invoice.currency, charge: false) }
36
+ invoice.charges.each { |c| allowance_charge(b, c, invoice.currency, charge: true) }
35
37
  tax_total(b, invoice)
36
38
  monetary_total(b, invoice)
37
39
  invoice.lines.each_with_index do |line, idx|
@@ -151,14 +153,42 @@ module Einvoicing
151
153
  end
152
154
  private_class_method :tax_total
153
155
 
156
+ # BG-20 (allowance) / BG-21 (charge) at document level.
157
+ def self.allowance_charge(b, item, currency, charge:)
158
+ b.tag("cac:AllowanceCharge") do
159
+ b.text("cbc:ChargeIndicator", charge.to_s)
160
+ b.text("cbc:AllowanceChargeReasonCode", item.reason_code) if item.reason_code
161
+ b.text("cbc:AllowanceChargeReason", item.reason) if item.reason
162
+ b.text("cbc:MultiplierFactorNumeric", format_amount(item.percentage)) if item.percentage
163
+ b.text("cbc:Amount", format_amount(item.amount), "currencyID" => currency)
164
+ if item.base_amount
165
+ b.text("cbc:BaseAmount", format_amount(item.base_amount), "currencyID" => currency)
166
+ end
167
+ b.tag("cac:TaxCategory") do
168
+ b.text("cbc:ID", item.tax_category_code)
169
+ b.text("cbc:Percent", format_amount(item.vat_rate_percent))
170
+ b.tag("cac:TaxScheme") { b.text("cbc:ID", "VAT") }
171
+ end
172
+ end
173
+ end
174
+ private_class_method :allowance_charge
175
+
154
176
  def self.monetary_total(b, invoice)
155
177
  b.tag("cac:LegalMonetaryTotal") do
156
- b.text("cbc:LineExtensionAmount", format_amount(invoice.net_total),
178
+ b.text("cbc:LineExtensionAmount", format_amount(invoice.line_total),
157
179
  "currencyID" => invoice.currency)
158
180
  b.text("cbc:TaxExclusiveAmount", format_amount(invoice.net_total),
159
181
  "currencyID" => invoice.currency)
160
182
  b.text("cbc:TaxInclusiveAmount", format_amount(invoice.gross_total),
161
183
  "currencyID" => invoice.currency)
184
+ if invoice.allowance_total.positive?
185
+ b.text("cbc:AllowanceTotalAmount", format_amount(invoice.allowance_total),
186
+ "currencyID" => invoice.currency)
187
+ end
188
+ if invoice.charge_total.positive?
189
+ b.text("cbc:ChargeTotalAmount", format_amount(invoice.charge_total),
190
+ "currencyID" => invoice.currency)
191
+ end
162
192
  if invoice.prepaid_amount.positive?
163
193
  b.text("cbc:PrepaidAmount", format_amount(invoice.prepaid_amount),
164
194
  "currencyID" => invoice.currency)
@@ -27,6 +27,8 @@ module Einvoicing
27
27
  :seller,
28
28
  :buyer,
29
29
  :lines,
30
+ :allowances,
31
+ :charges,
30
32
  :tax_breakdown,
31
33
  :payment_reference,
32
34
  :note,
@@ -39,12 +41,15 @@ module Einvoicing
39
41
  :prepaid_amount
40
42
  ) do
41
43
  def initialize(invoice_number:, issue_date:, seller:, buyer:, lines:,
44
+ allowances: [], charges: [],
42
45
  due_date: nil, currency: "EUR", tax_currency: nil, tax_breakdown: nil,
43
46
  payment_reference: nil, note: nil,
44
47
  payment_means_code: nil, iban: nil, bic: nil,
45
48
  document_type: :invoice, original_invoice_number: nil, original_invoice_date: nil,
46
49
  prepaid_amount: BigDecimal(0))
47
- computed_breakdown = tax_breakdown || compute_tax_breakdown(lines)
50
+ allowances = allowances || []
51
+ charges = charges || []
52
+ computed_breakdown = tax_breakdown || compute_tax_breakdown(lines, allowances, charges)
48
53
  super(
49
54
  invoice_number: invoice_number,
50
55
  issue_date: issue_date,
@@ -54,6 +59,8 @@ module Einvoicing
54
59
  seller: seller,
55
60
  buyer: buyer,
56
61
  lines: lines,
62
+ allowances: allowances,
63
+ charges: charges,
57
64
  tax_breakdown: computed_breakdown,
58
65
  payment_reference: payment_reference,
59
66
  note: note,
@@ -67,20 +74,49 @@ module Einvoicing
67
74
  )
68
75
  end
69
76
 
70
- # Sum of all line net amounts (excl. VAT).
71
- def net_total
77
+ # Data#with feeds every current member back through the constructor, which
78
+ # would carry the already-computed tax_breakdown over to the copy and leave
79
+ # it stale. Drop it so the copy recomputes — unless the caller supplies one.
80
+ RECOMPUTES_TAX_BREAKDOWN = %i[lines allowances charges].freeze
81
+
82
+ def with(**kwargs)
83
+ return super if kwargs.key?(:tax_breakdown)
84
+ return super if (kwargs.keys & RECOMPUTES_TAX_BREAKDOWN).empty?
85
+
86
+ super(**kwargs, tax_breakdown: nil)
87
+ end
88
+
89
+ # Sum of all line net amounts, before document-level adjustments (BT-106).
90
+ def line_total
72
91
  lines.sum(BigDecimal("0"), &:net_amount).round(2, :half_up)
73
92
  end
74
93
 
75
- # Total VAT across all lines.
94
+ # Sum of document-level allowances (BT-107).
95
+ def allowance_total
96
+ allowances.sum(BigDecimal("0"), &:amount).round(2, :half_up)
97
+ end
98
+
99
+ # Sum of document-level charges (BT-108).
100
+ def charge_total
101
+ charges.sum(BigDecimal("0"), &:amount).round(2, :half_up)
102
+ end
103
+
104
+ # Total amount without VAT (BT-109) — BR-CO-13:
105
+ # line_total − allowance_total + charge_total.
106
+ def net_total
107
+ (line_total - allowance_total + charge_total).round(2, :half_up)
108
+ end
109
+
110
+ # Total VAT across every category (BT-110).
76
111
  def tax_total
77
112
  tax_breakdown.sum(BigDecimal("0"), &:tax_amount).round(2, :half_up)
78
113
  end
79
114
 
80
- # Grand total including VAT — computed from per-line gross amounts to avoid
81
- # double-rounding through already-rounded net_total/tax_total (EN 16931 BR-CO-13).
115
+ # Grand total including VAT (BT-112) BR-CO-15: net_total + tax_total.
116
+ # Both operands are already rounded to 2 decimals from per-line amounts, so
117
+ # this introduces no double-rounding.
82
118
  def gross_total
83
- lines.sum(BigDecimal("0"), &:gross_amount).round(2, :half_up)
119
+ (net_total + tax_total).round(2, :half_up)
84
120
  end
85
121
 
86
122
  # Amount due after deducting any retained/prepaid amount (BT-113).
@@ -92,13 +128,35 @@ module Einvoicing
92
128
 
93
129
  private
94
130
 
95
- def compute_tax_breakdown(lines)
96
- grouped = lines.group_by { |l| [ l.vat_rate, l.category ] }
97
- grouped.map do |(rate, category), rate_lines|
98
- taxable = rate_lines.sum(BigDecimal("0"), &:net_amount).round(2, :half_up)
99
- tax_amt = rate_lines.sum(BigDecimal("0"), &:vat_amount).round(2, :half_up)
100
- Tax.new(rate: rate, taxable_amount: taxable, tax_amount: tax_amt, category: category)
131
+ # Per-category taxable base (BT-116) and VAT (BT-117), adjusted by the
132
+ # document-level allowances and charges that carry the same rate/category
133
+ # (EN 16931 BR-CO-10 through BR-CO-12).
134
+ def compute_tax_breakdown(lines, allowances, charges)
135
+ key = ->(item) { [ item.vat_rate, item.category ] }
136
+ grouped_lines = lines.group_by(&key)
137
+ grouped_allowances = allowances.group_by(&key)
138
+ grouped_charges = charges.group_by(&key)
139
+
140
+ (grouped_lines.keys | grouped_allowances.keys | grouped_charges.keys).map do |(rate, category)|
141
+ k = [ rate, category ]
142
+ rate_lines = grouped_lines.fetch(k, [])
143
+ rate_allowances = grouped_allowances.fetch(k, [])
144
+ rate_charges = grouped_charges.fetch(k, [])
145
+
146
+ taxable = sum_of(rate_lines, :net_amount) -
147
+ sum_of(rate_allowances, :amount) +
148
+ sum_of(rate_charges, :amount)
149
+ tax_amt = sum_of(rate_lines, :vat_amount) -
150
+ sum_of(rate_allowances, :vat_amount) +
151
+ sum_of(rate_charges, :vat_amount)
152
+
153
+ Tax.new(rate: rate, taxable_amount: taxable.round(2, :half_up),
154
+ tax_amount: tax_amt.round(2, :half_up), category: category)
101
155
  end
102
156
  end
157
+
158
+ def sum_of(items, method)
159
+ items.sum(BigDecimal("0"), &method)
160
+ end
103
161
  end
104
162
  end
@@ -70,10 +70,24 @@ module Einvoicing
70
70
  currency: respond_to?(:currency) ? (currency || "EUR") : "EUR",
71
71
  seller: einvoicing_seller,
72
72
  buyer: einvoicing_buyer,
73
- lines: einvoicing_lines
73
+ lines: einvoicing_lines,
74
+ allowances: einvoicing_allowances,
75
+ charges: einvoicing_charges
74
76
  )
75
77
  end
76
78
 
79
+ # Document-level allowances (BG-20). Override to return
80
+ # Array<Einvoicing::AllowanceCharge>.
81
+ def einvoicing_allowances
82
+ []
83
+ end
84
+
85
+ # Document-level charges (BG-21). Override to return
86
+ # Array<Einvoicing::AllowanceCharge>.
87
+ def einvoicing_charges
88
+ []
89
+ end
90
+
77
91
  # Generate CII D16B XML string.
78
92
  # @return [String]
79
93
  def to_cii_xml
@@ -29,7 +29,9 @@ module Einvoicing
29
29
  *validate_invoice_fields(invoice),
30
30
  *validate_party(invoice.seller, :seller),
31
31
  *validate_party(invoice.buyer, :buyer),
32
- *validate_lines(invoice.lines)
32
+ *validate_lines(invoice.lines),
33
+ *validate_allowances_charges(invoice.allowances, :allowance),
34
+ *validate_allowances_charges(invoice.charges, :charge)
33
35
  ]
34
36
  end
35
37
 
@@ -193,6 +195,31 @@ module Einvoicing
193
195
  end.compact
194
196
  end
195
197
  private_class_method :validate_lines
198
+
199
+ # Document-level allowances (BG-20) and charges (BG-21). EN 16931 BR-31 /
200
+ # BR-36 require an amount, BR-33 / BR-38 a reason or a reason code.
201
+ def self.validate_allowances_charges(items, kind)
202
+ return [] if items.nil? || items.empty?
203
+
204
+ items.each_with_index.flat_map do |item, idx|
205
+ n = idx + 1
206
+ [
207
+ (if item.amount.negative?
208
+ { field: :"#{kind}_#{n}_amount", error: :amount_invalid,
209
+ message: Einvoicing::I18n.t("errors.#{kind}.amount_invalid", index: n) }
210
+ end),
211
+ (if item.reason.to_s.strip.empty? && item.reason_code.to_s.strip.empty?
212
+ { field: :"#{kind}_#{n}_reason", error: :reason_missing,
213
+ message: Einvoicing::I18n.t("errors.#{kind}.reason_missing", index: n) }
214
+ end),
215
+ (unless [ 0.0, 0.055, 0.10, 0.20 ].include?(item.vat_rate.to_f.round(3))
216
+ { field: :"#{kind}_#{n}_vat_rate", error: :vat_rate_invalid,
217
+ message: Einvoicing::I18n.t("errors.#{kind}.vat_rate_invalid", index: n) }
218
+ end)
219
+ ]
220
+ end.compact
221
+ end
222
+ private_class_method :validate_allowances_charges
196
223
  end
197
224
  end
198
225
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Einvoicing
4
- VERSION = "0.7.1"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/einvoicing.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "einvoicing/party"
11
11
  require_relative "einvoicing/siret_lookup"
12
12
  require_relative "einvoicing/fr"
13
13
  require_relative "einvoicing/line_item"
14
+ require_relative "einvoicing/allowance_charge"
14
15
  require_relative "einvoicing/invoice"
15
16
  require_relative "einvoicing/xml_builder"
16
17
  require_relative "einvoicing/formats/cii"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: einvoicing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Le Ray
@@ -165,6 +165,7 @@ files:
165
165
  - config/locales/einvoicing.en.yml
166
166
  - config/locales/einvoicing.fr.yml
167
167
  - lib/einvoicing.rb
168
+ - lib/einvoicing/allowance_charge.rb
168
169
  - lib/einvoicing/data/PEPPOL-EN16931-UBL.xslt
169
170
  - lib/einvoicing/data/srgb.icc
170
171
  - lib/einvoicing/errors.rb