factur-x-builder 0.1.0 → 0.1.2

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: 73b17dce8190b48d61b4b2f1d521396374ef906351888926d986fb45268f0226
4
- data.tar.gz: a35e0da9ed5066ef58d5ba29adf2168d86de8d16c3ec8bfee0d5503aae282de2
3
+ metadata.gz: 2f380d59a7b981bd4999314d3321a99dc762ac9c67709f6e543cc5632eade2c4
4
+ data.tar.gz: 553e1dda58b31b9804ede1acc9c75ff4ee515b1bcf152a508fb56bca7ebf2357
5
5
  SHA512:
6
- metadata.gz: ca76d2866e7950a772f07b2dc4b17ea2390a1ea8f1b450b2daff5d21347662b0f32cc8bb2e33bc020c39e12d9a5a40096e33b1912a56bdd7ebc5d1a9fc50ca28
7
- data.tar.gz: d5661ef43aad892fd4bb67a56714455c1b959545e13cbf4ecb6d88292e41d5bf8e1087277d0999fe4473b0e941010d60a7f34b6d0215ba5e1cc214c84f90c981
6
+ metadata.gz: 54363eb24098df15598c699e48956247464bc739435af190736762e0707b77513f98e12b836db743e832b98672ef989fc225833c08ab06402b391bd10474f83d
7
+ data.tar.gz: 71355fc896353819a7d66da2b3d1fb078f251184858add396e3d25bc6475535c6eb7593ffeb6ec23b48fb4900e9fe8a76440b8576e10dc20173c41e556461223
data/.gitignore CHANGED
@@ -11,3 +11,4 @@
11
11
  .ruby-version
12
12
  .nvmrc
13
13
  .DS_Store
14
+ *.gem
data/CHANGELOG.md CHANGED
@@ -1,6 +1,36 @@
1
+ ## [0.1.2] - 2026-08-15
2
+
3
+ ### Added
4
+
5
+ - `Codes::PaymentMeansType::ALL` lists the 84 UNTDID 4461 codes EN 16931 permits for BT-81, and a
6
+ payment means outside that list is now rejected instead of producing XML the schematron refuses.
7
+ More codes are named (`PAYMENT_TO_BANK_ACCOUNT`, `CREDIT_CARD`, `ONLINE_PAYMENT_SERVICE`,
8
+ `MUTUALLY_DEFINED`, …); the constants are a convenience, not the permitted set.
9
+ - BT-87 is checked against BR-51, which caps a card number at 10 characters — the first 6 and last
10
+ 4 digits PCI DSS allows to be displayed. Masking the middle with asterisks overflows that limit
11
+ and was previously emitted, producing XML the schematron refuses.
12
+ - Unit prices are checked against BR-27 and BR-28: a negative BT-146 or BT-148 is rejected with a
13
+ message pointing at credit notes, which use type code 381 with positive amounts.
14
+
15
+ ## [0.1.1] - 2026-08-10
16
+
17
+ ### Fixed
18
+
19
+ - The vendored XSD files were renamed without updating the `schemaLocation` of the files that
20
+ import them, which made every `FacturX.build` raise `Nokogiri::XML::SyntaxError`. A test now
21
+ checks that the schema compiles and that every `schemaLocation` resolves to an existing file.
22
+
23
+ ### Added
24
+
25
+ - `Party#legal_information` (BT-33, seller additional legal information), emitted as
26
+ `SellerTradeParty/Description`: legal form, share capital, RCS registration, or a registered
27
+ office that differs from the invoicing establishment. EN 16931 allows a single postal address
28
+ per party, so this is the only structured place for a second one.
29
+
1
30
  ## [0.1.0] - 2026-08-05
2
31
 
3
32
  - Initial release
33
+
4
34
  ### Added
5
35
 
6
36
  - `FacturX.build` generates an EN 16931 `factur-x.xml` document from invoicing domain objects:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- factur-x-builder (0.1.0)
4
+ factur-x-builder (0.1.1)
5
5
  nokogiri (~> 1.15, < 1.16)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -112,6 +112,73 @@ FacturX.violations(invoice) # => ["...", "..."]
112
112
  FacturX.valid?(invoice) # => false
113
113
  ```
114
114
 
115
+ ### Payment states
116
+
117
+ An issued invoice is immutable. Payments received **after** issuance are not written back into it —
118
+ they are reported to the platform as lifecycle statuses (`Encaissée`), a separate message flow that
119
+ this gem does not produce and is not meant to.
120
+
121
+ What the invoice records is the state **at issuance**, through BT-113 and BT-115:
122
+
123
+ | Situation | `prepaid` (BT-113) | `due_payable` (BT-115) | `type_code` |
124
+ |---|---|---|---|
125
+ | Nothing paid | omitted | grand total | `380` |
126
+ | Paid before issuance | grand total | `0.00` | `380` |
127
+ | Deposit collected before issuance | the deposit | the balance | `380` |
128
+ | Cancellation | omitted | amount to refund | `381` + `preceding_invoices` |
129
+
130
+ `BR-CO-16` ties them together — `BT-115 = BT-112 − BT-113 + BT-114` — and is checked before
131
+ anything is generated.
132
+
133
+ A credit note carries **positive** amounts; the type code conveys the direction. Negative unit
134
+ prices are rejected (`BR-27`, `BR-28`).
135
+
136
+ ### Payment instructions are optional
137
+
138
+ `payment_means` maps to BG-16 *Payment instructions*, which both schematrons accept as absent. It
139
+ states how the seller **expects** to be paid — it is not a record of the buyer's choice, since the
140
+ invoice precedes the payment. Several means may be declared at once.
141
+
142
+ Once a means is declared, the rules apply: a credit transfer (`30`, `58`) requires an account
143
+ identifier (BR-61, BR-CO-27), and the code must belong to UNTDID 4461 —
144
+ `Codes::PaymentMeansType::ALL` holds the 84 permitted values, checked against the code database the
145
+ official schematron uses. The named constants (`CREDIT_TRANSFER`, `BANK_CARD`, …) cover the common
146
+ cases; any other code can be passed as a plain string.
147
+
148
+ A platform may narrow the list further. Chorus Pro accepts only `30`, `42`, `48`, `49`, `58`, `59`
149
+ and `97` for the public sector — a restriction no schematron encodes, so the gem cannot catch it.
150
+
151
+ Nothing downstream contradicts it. The `Encaissée` lifecycle status reports only the invoice number,
152
+ the payment date and the amount collected per VAT rate — the means actually used is not among the
153
+ data transmitted, so a buyer paying by cheque against an invoice quoting a transfer raises no
154
+ compliance issue.
155
+
156
+ ### One address per party
157
+
158
+ EN 16931 gives each party exactly one `PostalTradeAddress`, and it must be the address of the
159
+ establishment identified by the SIRET carried in `GlobalID`. There is no second slot: the element
160
+ that would hold a registered office, `SpecifiedLegalOrganization/PostalTradeAddress`, exists in the
161
+ `extended` XSD but the French CTC schematron forbids it (`CII-SR-225`), as it forbids
162
+ `ShipFromTradeParty` (`CII-SR-166`).
163
+
164
+ When the registered office differs from the invoicing establishment, it belongs in BT-33:
165
+
166
+ ```ruby
167
+ FacturX::Party.new(
168
+ name: "OFFICE DE TOURISME DES MONTS FICTIFS - Établissement de Chambéry",
169
+ legal_information: "SARL au capital de 50 000 € - RCS Clermont-Ferrand 398 765 438 - " \
170
+ "Siège social : 4 boulevard des Sources, 63240 LE MONT-FICTIF",
171
+ siret: "39876543800005",
172
+ siren: "398765438",
173
+ vat_number: "FR79398765438",
174
+ address: FacturX::Address.new(line_one: "8 rue des Entrepôts", postcode: "73000",
175
+ city: "Chambéry", country_code: "FR")
176
+ )
177
+ ```
178
+
179
+ BT-33 is free text — the counterpart of `cbc:CompanyLegalForm` in UBL. Receivers display it, they do
180
+ not parse it.
181
+
115
182
  ### Profiles
116
183
 
117
184
  By default the French e-invoicing rules apply on top of EN 16931: an invoicing mode (BT-23) and the
@@ -37,15 +37,34 @@ module FacturX
37
37
  REQUIRING_EXEMPTION_REASON = [EXEMPT, REVERSE_CHARGE, INTRA_COMMUNITY, EXPORT].freeze
38
38
  end
39
39
 
40
+ # NOTE: Chorus Pro only accepts 30, 42, 48, 49, 58, 59 and 97
40
41
  module PaymentMeansType
42
+ NOT_DEFINED = "1"
41
43
  CASH = "10"
42
44
  CHEQUE = "20"
43
45
  CREDIT_TRANSFER = "30"
46
+ PAYMENT_TO_BANK_ACCOUNT = "42"
47
+ # The instrument, whatever the channel: a card read on a physical
48
+ # terminal and a card entered on a web page are both 48.
44
49
  BANK_CARD = "48"
45
50
  DIRECT_DEBIT = "49"
51
+ CREDIT_CARD = "54"
52
+ DEBIT_CARD = "55"
53
+ STANDING_AGREEMENT = "57"
46
54
  SEPA_CREDIT_TRANSFER = "58"
47
55
  SEPA_DIRECT_DEBIT = "59"
48
- NOT_DEFINED = "1"
56
+ # The service is itself the instrument - a wallet or a provider that
57
+ # collects in its own name. A card payment through a payment page is
58
+ # still BANK_CARD, not this.
59
+ ONLINE_PAYMENT_SERVICE = "68"
60
+ CLEARING_BETWEEN_PARTNERS = "97"
61
+ MUTUALLY_DEFINED = "ZZZ"
62
+
63
+ # The complete UNTDID 4461 subset of the EN 16931 code list, as published
64
+ # in the Factur-X code database used by the official schematron.
65
+ ALL = (
66
+ (1..70).to_a + (74..78).to_a + (91..98).to_a
67
+ ).map(&:to_s).push("ZZZ").freeze
49
68
 
50
69
  REQUIRING_CREDITOR_ACCOUNT = [CREDIT_TRANSFER, SEPA_CREDIT_TRANSFER].freeze
51
70
  REQUIRING_MANDATE = [DIRECT_DEBIT, SEPA_DIRECT_DEBIT].freeze
@@ -306,6 +306,7 @@ module FacturX
306
306
  xml["ram"].send(element_name) do
307
307
  text(xml, :GlobalID, party.global_id, scheme_attribute(party.global_id_scheme))
308
308
  text(xml, :Name, party.name)
309
+ text(xml, :Description, party.legal_information)
309
310
  emit_legal_organization(xml, party)
310
311
  emit_contact(xml, party)
311
312
  emit_address(xml, party.address)
data/lib/factur_x/line.rb CHANGED
@@ -7,7 +7,7 @@ module FacturX
7
7
  class Line
8
8
  attr_reader :number, :name, :description, :seller_item_id, :buyer_item_id,
9
9
  :global_id, :global_id_scheme, :unit_price, :gross_unit_price,
10
- :unit_discount, :quantity, :unit_code, :vat_category, :vat_rate,
10
+ :unit_discount, :quantity, :unit_code, :vat_category, :vat_rate, :vat_amount,
11
11
  :net_amount, :note, :origin_country, :period_start, :period_end,
12
12
  :order_line_id, :accounting_reference
13
13
 
@@ -15,7 +15,7 @@ module FacturX
15
15
  number: nil, description: nil, seller_item_id: nil, buyer_item_id: nil,
16
16
  global_id: nil, global_id_scheme: nil, gross_unit_price: nil,
17
17
  unit_discount: nil, unit_code: Codes::PIECE_UNIT,
18
- vat_category: Codes::VatCategory::STANDARD, vat_rate: nil,
18
+ vat_category: Codes::VatCategory::STANDARD, vat_rate: nil, vat_amount: nil,
19
19
  note: nil, origin_country: nil, period_start: nil, period_end: nil,
20
20
  order_line_id: nil, accounting_reference: nil)
21
21
  @number = number
@@ -32,6 +32,7 @@ module FacturX
32
32
  @unit_code = unit_code
33
33
  @vat_category = vat_category
34
34
  @vat_rate = vat_rate
35
+ @vat_amount = vat_amount
35
36
  @net_amount = net_amount
36
37
  @note = note
37
38
  @origin_country = origin_country
@@ -72,8 +73,8 @@ module FacturX
72
73
  global_id: global_id, global_id_scheme: global_id_scheme,
73
74
  unit_price: unit_price, gross_unit_price: gross_unit_price,
74
75
  unit_discount: unit_discount, quantity: quantity, unit_code: unit_code,
75
- vat_category: vat_category, vat_rate: vat_rate, net_amount: net_amount,
76
- note: note, origin_country: origin_country,
76
+ vat_category: vat_category, vat_rate: vat_rate, vat_amount: vat_amount,
77
+ net_amount: net_amount, note: note, origin_country: origin_country,
77
78
  period_start: period_start, period_end: period_end,
78
79
  order_line_id: order_line_id, accounting_reference: accounting_reference
79
80
  }
@@ -9,18 +9,26 @@ module FacturX
9
9
  # (scheme 0009), `siren` the legal organization ID (scheme 0002), and
10
10
  # `routing_id` the electronic address (scheme 0225) used to route the
11
11
  # invoice through the PPF, defaulting to the SIRET.
12
+ #
13
+ # `legal_information` is BT-33, the seller's additional legal information:
14
+ # legal form, share capital, RCS registration, and any other statement the
15
+ # printed invoice must carry. A party holds a single postal address, so this
16
+ # is also where a registered office that differs from the invoicing
17
+ # establishment belongs.
12
18
  class Party
13
- attr_reader :name, :trading_name, :siret, :siren, :vat_number, :tax_id,
19
+ attr_reader :name, :trading_name, :legal_information, :siret, :siren, :vat_number, :tax_id,
14
20
  :address, :contact, :global_id, :global_id_scheme,
15
21
  :legal_id, :legal_id_scheme, :routing_id, :routing_id_scheme
16
22
 
17
23
  def initialize(name:, address: nil, contact: nil, trading_name: nil,
24
+ legal_information: nil,
18
25
  siret: nil, siren: nil, vat_number: nil, tax_id: nil,
19
26
  global_id: nil, global_id_scheme: nil,
20
27
  legal_id: nil, legal_id_scheme: nil,
21
28
  routing_id: :derive_from_siret, routing_id_scheme: nil)
22
29
  @name = name
23
30
  @trading_name = trading_name
31
+ @legal_information = legal_information
24
32
  @address = address
25
33
  @contact = contact
26
34
  @siret = siret
@@ -5,8 +5,8 @@
5
5
  xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
6
6
  targetNamespace="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
7
7
  elementFormDefault="qualified">
8
- <xs:import namespace="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd"/>
9
- <xs:import namespace="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd"/>
8
+ <xs:import namespace="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_QualifiedDataType_100.xsd"/>
9
+ <xs:import namespace="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_UnqualifiedDataType_100.xsd"/>
10
10
  <xs:complexType name="CreditorFinancialAccountType">
11
11
  <xs:sequence>
12
12
  <xs:element name="IBANID" type="udt:IDType" minOccurs="0"/>
@@ -6,9 +6,9 @@
6
6
  xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
7
7
  targetNamespace="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
8
8
  elementFormDefault="qualified">
9
- <xs:import namespace="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_urn_un_unece_uncefact_data_standard_QualifiedDataType_100.xsd"/>
10
- <xs:import namespace="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" schemaLocation="Factur-X_1.09_EN16931_urn_un_unece_uncefact_data_standard_ReusableAggregateBusinessInformationEntity_100.xsd"/>
11
- <xs:import namespace="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_urn_un_unece_uncefact_data_standard_UnqualifiedDataType_100.xsd"/>
9
+ <xs:import namespace="urn:un:unece:uncefact:data:standard:QualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_QualifiedDataType_100.xsd"/>
10
+ <xs:import namespace="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" schemaLocation="Factur-X_1.09_EN16931_ReusableAggregateBusinessInformationEntity_100.xsd"/>
11
+ <xs:import namespace="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" schemaLocation="Factur-X_1.09_EN16931_UnqualifiedDataType_100.xsd"/>
12
12
  <xs:element name="CrossIndustryInvoice" type="rsm:CrossIndustryInvoiceType"/>
13
13
  <xs:complexType name="CrossIndustryInvoiceType">
14
14
  <xs:sequence>
@@ -22,6 +22,8 @@ module FacturX
22
22
 
23
23
  ROUNDING_TOLERANCE = BigDecimal("0.01")
24
24
 
25
+ CARD_NUMBER_MAX_LENGTH = 10
26
+
25
27
  PROFILES = %i[en16931 fr_ctc].freeze
26
28
 
27
29
  # BR-FR-05: every invoice must carry these three statements.
@@ -174,7 +176,25 @@ module FacturX
174
176
  add("#{label}: name is required (BT-153)") if blank?(line.name)
175
177
  add("#{label}: unit code is required (BT-130)") if blank?(line.unit_code)
176
178
  check_vat_category(line.vat_category, line.vat_rate, label)
179
+ check_unit_prices(line, label)
180
+ end
181
+ end
182
+
183
+ # A credit note carries positive amounts: the document type code (BT-3)
184
+ # conveys the direction, not the sign of the prices.
185
+ def check_unit_prices(line, label)
186
+ if negative?(line.unit_price)
187
+ add("#{label}: unit price must not be negative (BT-146, BR-27) - a credit note uses " \
188
+ "type code #{Codes::CREDIT_NOTE} with positive amounts")
177
189
  end
190
+
191
+ return unless negative?(line.gross_unit_price)
192
+
193
+ add("#{label}: gross unit price must not be negative (BT-148, BR-28)")
194
+ end
195
+
196
+ def negative?(value)
197
+ !value.nil? && Formatting.decimal(value).negative?
178
198
  end
179
199
 
180
200
  def check_vat_category(category, rate, label)
@@ -274,6 +294,9 @@ module FacturX
274
294
  [item.vat_category, Formatting.decimal(item.vat_rate || 0)]
275
295
  end
276
296
 
297
+ # Payment instructions (BG-16) are optional: an invoice with no payment
298
+ # means at all satisfies both schematrons. What is checked here applies
299
+ # only once a payment means has been declared.
277
300
  def check_payment
278
301
  invoice.payment_means.each do |means|
279
302
  if blank?(means.type_code)
@@ -281,18 +304,39 @@ module FacturX
281
304
  next
282
305
  end
283
306
 
307
+ unless Codes::PaymentMeansType::ALL.include?(means.type_code.to_s)
308
+ add("payment means #{means.type_code.inspect} is not a UNTDID 4461 code (BT-81)")
309
+ next
310
+ end
311
+
284
312
  if Codes::PaymentMeansType::REQUIRING_CREDITOR_ACCOUNT.include?(means.type_code) &&
285
313
  !means.creditor_account?
286
314
  add("payment means #{means.type_code} (credit transfer) requires a creditor " \
287
- "account identifier (BR-61)")
315
+ "account identifier - an IBAN or a proprietary account id (BR-61, BR-CO-27)")
288
316
  end
289
317
 
290
318
  if means.iban && !means.iban.to_s.delete(" ").match?(IBAN_PATTERN)
291
319
  add("payment IBAN #{means.iban.inspect} is not a well-formed IBAN")
292
320
  end
321
+
322
+ check_card_number(means)
293
323
  end
294
324
  end
295
325
 
326
+ # BR-51 caps BT-87 at 10 characters, which is what PCI DSS allows to be
327
+ # displayed: the first 6 and last 4 digits. Masking the middle with
328
+ # asterisks overflows that limit and is rejected.
329
+ def check_card_number(means)
330
+ return if means.card_id.nil?
331
+
332
+ length = means.card_id.to_s.strip.length
333
+ return if length <= CARD_NUMBER_MAX_LENGTH
334
+
335
+ add("payment card number is #{length} characters; an invoice must never carry a full " \
336
+ "card number (BT-87, BR-51). Keep at most #{CARD_NUMBER_MAX_LENGTH} characters - " \
337
+ "the first 6 and last 4 digits, without masking characters")
338
+ end
339
+
296
340
  def check_totals
297
341
  totals = invoice.totals
298
342
  if totals.nil?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FacturX
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: factur-x-builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hotentic
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-09 00:00:00.000000000 Z
11
+ date: 2026-08-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri