factur-x-builder 0.1.1 → 0.1.3

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: ac6a0f9f68bea474096e87b73b676318343d3aff63d83dbc452f742a67ebb8f8
4
- data.tar.gz: 7c07d34573f36f5f16ce094ab8f84df948d288ab052e3a3c69ea1370176b1470
3
+ metadata.gz: 224858d0b676c38126632f450793b3ea65302d795ee6eba2f5fd34c532d7bc51
4
+ data.tar.gz: 67e574e617b043c7e2e433b95c17193c31dcd98df49d0d454338773a2b461acc
5
5
  SHA512:
6
- metadata.gz: 3cff2a09eae0ec30028c99e5453a5149154b9b45869dfa4bd88950e4803aeb58af4455f49b154f2e1b715ffe5877e38cdff482b0e190a57bab9fc4cc9af1afdd
7
- data.tar.gz: 7376dda081fe40784a55c71554215951cf7ec4d0f2a2f67e76e0370bbf0b8b927da565e1754722e2890b8591becb670ce3472d523f0040569f05a9d65e56e72e
6
+ metadata.gz: 3a674455d7d5c036df7469dd489cd4d932912b1f0113a2aaecb48508a4f48abd4efd42174cdc525123f69e48b81193f8e052b8cbf2c72abe196f55f74d7c747d
7
+ data.tar.gz: d283359e423f7e88dcfd0a88cd56721812ed6be147dc9de9ce39f5cc784740115531011a43941c4174673c3b1baf137e47d91e9a88cdb266af24af783139c7e0
data/.rubocop.yml CHANGED
@@ -27,3 +27,6 @@ Style/StringLiteralsInInterpolation:
27
27
 
28
28
  Layout/LineLength:
29
29
  Max: 120
30
+
31
+ Gemspec/RequireMFA:
32
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,4 +1,18 @@
1
- ## [Unreleased]
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
2
16
 
3
17
  ### Fixed
4
18
 
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.3)
5
5
  nokogiri (~> 1.15, < 1.16)
6
6
 
7
7
  GEM
@@ -12,6 +12,8 @@ GEM
12
12
  language_server-protocol (3.17.0.6)
13
13
  lint_roller (1.1.0)
14
14
  minitest (5.26.1)
15
+ nokogiri (1.15.7-arm64-darwin)
16
+ racc (~> 1.4)
15
17
  nokogiri (1.15.7-x86_64-darwin)
16
18
  racc (~> 1.4)
17
19
  parallel (1.28.0)
@@ -43,6 +45,7 @@ GEM
43
45
  unicode-emoji (4.2.0)
44
46
 
45
47
  PLATFORMS
48
+ arm64-darwin-25
46
49
  x86_64-darwin-21
47
50
 
48
51
  DEPENDENCIES
data/README.md CHANGED
@@ -112,6 +112,64 @@ 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
+ ### Routing address
137
+
138
+ `routing_id` is the electronic address the platform delivers to (BT-34 for the seller, BT-49 for the
139
+ buyer). It defaults to the SIRET, and its scheme identifier — mandatory under BR-62 and BR-63 — is
140
+ filled in as `0225`.
141
+
142
+ That code designates the addressing of the French reform itself, not a particular identifier, so it
143
+ stays `0225` whatever the directory registered: SIREN, SIRET, `SIREN_SIRET`, or
144
+ `SIREN_SIRET_CODE_ROUTAGE` for an address finer than the establishment. The value is opaque to the
145
+ gem — forwarded verbatim, never parsed — and every shape is covered by the conformance suite.
146
+
147
+ ```ruby
148
+ FacturX::Party.new(name: "…", siret: "39876543800005",
149
+ routing_id: "398765438_39876543800005_COMPTA")
150
+ # => <ram:URIID schemeID="0225">398765438_39876543800005_COMPTA</ram:URIID>
151
+ ```
152
+
153
+ ### Payment instructions are optional
154
+
155
+ `payment_means` maps to BG-16 *Payment instructions*, which both schematrons accept as absent. It
156
+ states how the seller **expects** to be paid — it is not a record of the buyer's choice, since the
157
+ invoice precedes the payment. Several means may be declared at once.
158
+
159
+ Once a means is declared, the rules apply: a credit transfer (`30`, `58`) requires an account
160
+ identifier (BR-61, BR-CO-27), and the code must belong to UNTDID 4461 —
161
+ `Codes::PaymentMeansType::ALL` holds the 84 permitted values, checked against the code database the
162
+ official schematron uses. The named constants (`CREDIT_TRANSFER`, `BANK_CARD`, …) cover the common
163
+ cases; any other code can be passed as a plain string.
164
+
165
+ A platform may narrow the list further. Chorus Pro accepts only `30`, `42`, `48`, `49`, `58`, `59`
166
+ and `97` for the public sector — a restriction no schematron encodes, so the gem cannot catch it.
167
+
168
+ Nothing downstream contradicts it. The `Encaissée` lifecycle status reports only the invoice number,
169
+ the payment date and the amount collected per VAT rate — the means actually used is not among the
170
+ data transmitted, so a buyer paying by cheque against an invoice quoting a transfer raises no
171
+ compliance issue.
172
+
115
173
  ### One address per party
116
174
 
117
175
  EN 16931 gives each party exactly one `PostalTradeAddress`, and it must be the address of 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
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
  }
@@ -10,6 +10,12 @@ module FacturX
10
10
  # `routing_id` the electronic address (scheme 0225) used to route the
11
11
  # invoice through the PPF, defaulting to the SIRET.
12
12
  #
13
+ # Scheme 0225 designates the addressing of the French reform itself, not a
14
+ # particular identifier, so it holds whatever shape the directory registered:
15
+ # SIREN, SIRET, SIREN_SIRET, or SIREN_SIRET_CODE_ROUTAGE for an address finer
16
+ # than the establishment. The value is opaque here - it is forwarded verbatim,
17
+ # never parsed - so a new directory shape needs no change to this gem.
18
+ #
13
19
  # `legal_information` is BT-33, the seller's additional legal information:
14
20
  # legal form, share capital, RCS registration, and any other statement the
15
21
  # printed invoice must carry. A party holds a single postal address, so this
@@ -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.1"
4
+ VERSION = "0.1.3"
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.1
4
+ version: 0.1.3
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-10 00:00:00.000000000 Z
11
+ date: 2026-09-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri