einvoicing 0.7.0 → 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: 7581f06decdc863a7f8c7509a3ff696f73ca8d223579aa4695a3eee160f3e196
4
- data.tar.gz: 1355d8b9034f874d7f3bd7c38315428c90477c6f987ef9642e0deca071f17587
3
+ metadata.gz: d547dfe66db0d1629517aae3053ac762eb5f67757f79cea0c143c1c64b8a45dc
4
+ data.tar.gz: 2c30feb03f0679e0a98f45f96f42df87552316a4aaa64070d1a62904c7f788b8
5
5
  SHA512:
6
- metadata.gz: '0146294e7c5ea5515211eabf88d54dde12525052409b69a29aa4206df02762622a96b92bfd2c9e5afef820a50b4c34d939ba2e52baa4c217adf843b324bb90b6'
7
- data.tar.gz: 19dac11dd07c6dafb9856fe0878201de2ddc259678bbddef425107413a2d4649058568d6fdae988002df71223b4165b1aee38d3692bf4f7ec390a874b61a8b45
6
+ metadata.gz: a1869726902c83f9942dcc7a1eaa667e9ef6acca8c142310b2b7cec2f02dd8221756db936753ae91a5c7c5512900edeeddd56bef76ad323f9164313861259fbe
7
+ data.tar.gz: cd041966fd406bdcefbedec61ff00af79eb67a62a1ef7cce48fd0c2098e5c55251bffe16da93906d278e1993458813cbf56cabfd6af4a75e1f66d898b4e7cb6a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ 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
+
21
+ ## [0.7.1] - 2026-07-16
22
+
23
+ ### Fixed
24
+ - CII credit notes now emit the preceding invoice number (BT-25) and optional issue date (BT-26) as a machine-readable EN 16931 BG-3 reference while retaining the legacy human-readable note when no explicit invoice note is provided
25
+
10
26
  ## [0.7.0] - 2026-07-14
11
27
 
12
28
  ### Added
@@ -91,5 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
91
107
  - Zero runtime dependencies beyond hexapdf (stdlib-only XML generation via internal builder)
92
108
  - RSpec test suite
93
109
 
94
- [Unreleased]: https://github.com/sxnlabs/einvoicing/compare/v0.1.0...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
112
+ [0.7.1]: https://github.com/sxnlabs/einvoicing/compare/v0.7.0...v0.7.1
95
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
@@ -51,22 +51,26 @@ module Einvoicing
51
51
  b.tag("ram:IssueDateTime") do
52
52
  b.text("udt:DateTimeString", format_date(invoice.issue_date), "format" => "102")
53
53
  end
54
- if invoice.document_type == :credit_note && invoice.original_invoice_number
54
+ if (note = document_note(invoice))
55
55
  b.tag("ram:IncludedNote") do
56
- note = "Credit note for invoice #{invoice.original_invoice_number}"
57
- note += " dated #{invoice.original_invoice_date.strftime('%d/%m/%Y')}" if invoice.original_invoice_date
58
56
  b.text("ram:Content", note)
59
57
  end
60
58
  end
61
- if invoice.note
62
- b.tag("ram:IncludedNote") do
63
- b.text("ram:Content", invoice.note)
64
- end
65
- end
66
59
  end
67
60
  end
68
61
  private_class_method :exchanged_document
69
62
 
63
+ def self.document_note(invoice)
64
+ return invoice.note unless invoice.note.to_s.strip.empty?
65
+ return unless invoice.document_type == :credit_note
66
+ return if invoice.original_invoice_number.to_s.strip.empty?
67
+
68
+ note = "Credit note for invoice #{invoice.original_invoice_number}"
69
+ note += " dated #{format_display_date(invoice.original_invoice_date)}" if invoice.original_invoice_date
70
+ note
71
+ end
72
+ private_class_method :document_note
73
+
70
74
  def self.supply_chain_trade_transaction(b, invoice, profile)
71
75
  b.tag("rsm:SupplyChainTradeTransaction") do
72
76
  invoice.lines.each_with_index do |line, idx|
@@ -178,6 +182,9 @@ module Einvoicing
178
182
  end
179
183
  end
180
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
+
181
188
  if invoice.due_date
182
189
  b.tag("ram:SpecifiedTradePaymentTerms") do
183
190
  b.tag("ram:DueDateDateTime") do
@@ -187,7 +194,9 @@ module Einvoicing
187
194
  end
188
195
 
189
196
  b.tag("ram:SpecifiedTradeSettlementHeaderMonetarySummation") do
190
- 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?
191
200
  b.text("ram:TaxBasisTotalAmount", format_amount(invoice.net_total))
192
201
  b.text("ram:TaxTotalAmount", format_amount(invoice.tax_total),
193
202
  "currencyID" => invoice.currency)
@@ -195,10 +204,46 @@ module Einvoicing
195
204
  b.text("ram:TotalPrepaidAmount", format_amount(invoice.prepaid_amount)) if invoice.prepaid_amount.positive?
196
205
  b.text("ram:DuePayableAmount", format_amount(invoice.due_amount))
197
206
  end
207
+
208
+ preceding_invoice_reference(b, invoice) if invoice.document_type == :credit_note &&
209
+ invoice.original_invoice_number
198
210
  end
199
211
  end
200
212
  private_class_method :header_trade_settlement
201
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
+
235
+ def self.preceding_invoice_reference(b, invoice)
236
+ b.tag("ram:InvoiceReferencedDocument") do
237
+ b.text("ram:IssuerAssignedID", invoice.original_invoice_number)
238
+ if invoice.original_invoice_date
239
+ b.tag("ram:FormattedIssueDateTime") do
240
+ b.text("qdt:DateTimeString", format_date(invoice.original_invoice_date), "format" => "102")
241
+ end
242
+ end
243
+ end
244
+ end
245
+ private_class_method :preceding_invoice_reference
246
+
202
247
  # Format a Date or string as YYYYMMDD (CII date format 102).
203
248
  def self.format_date(date)
204
249
  d = date.is_a?(Date) ? date : Date.parse(date.to_s)
@@ -206,6 +251,12 @@ module Einvoicing
206
251
  end
207
252
  private_class_method :format_date
208
253
 
254
+ def self.format_display_date(date)
255
+ d = date.is_a?(Date) ? date : Date.parse(date.to_s)
256
+ d.strftime("%d/%m/%Y")
257
+ end
258
+ private_class_method :format_display_date
259
+
209
260
  def self.format_amount(value)
210
261
  format("%.2f", value)
211
262
  end
@@ -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.0"
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.0
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