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 +4 -4
- data/CHANGELOG.md +19 -1
- data/README.md +33 -0
- data/config/locales/einvoicing.en.yml +8 -0
- data/config/locales/einvoicing.fr.yml +8 -0
- data/lib/einvoicing/allowance_charge.rb +60 -0
- data/lib/einvoicing/formats/cii.rb +60 -9
- data/lib/einvoicing/formats/ubl.rb +31 -1
- data/lib/einvoicing/invoice.rb +71 -13
- data/lib/einvoicing/invoiceable.rb +15 -1
- data/lib/einvoicing/validators/fr.rb +28 -1
- data/lib/einvoicing/version.rb +1 -1
- data/lib/einvoicing.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d547dfe66db0d1629517aae3053ac762eb5f67757f79cea0c143c1c64b8a45dc
|
|
4
|
+
data.tar.gz: 2c30feb03f0679e0a98f45f96f42df87552316a4aaa64070d1a62904c7f788b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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)
|
data/lib/einvoicing/invoice.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
71
|
-
|
|
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
|
-
#
|
|
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 —
|
|
81
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
data/lib/einvoicing/version.rb
CHANGED
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.
|
|
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
|