Module: Ksef::FA3::Formatting
- Defined in:
- lib/ksef/fa3/formatting.rb
Overview
Value formatting for FA(3) documents (DESIGN.md §7.5).
Centralised deliberately: every monetary amount, date and flag in the document goes through here, so the rules live in one place rather than being reinvented per field.
Constant Summary collapse
- AMOUNT_SCALE =
Amounts are written with exactly two decimal places.
BigDecimal#to_swould emit scientific notation ("0.15e4"), which the schema's decimal types reject. 2- QUANTITY_SCALE =
TIlosciisfractionDigits="6". Quantities are allowed more precision than amounts, but not unlimited precision, and exceeding it is a schema rejection rather than a rounding difference. 6- UNIT_PRICE_SCALE =
Not every monetary element is
TKwotowy. A unit price isTKwotowy2, which istotalDigits="22"andfractionDigits="8"— "Wartość numeryczna 22 znaki max, w tym 8 znaków po przecinku". Four elements use it:P_9AandP_9B(net and gross unit price),P_10(discount) andP_9AZ(an order position's unit price). This model carries two of them, Line#net_unit_price and OrderLine#net_unit_price.Rounding those to two places was a silent alteration of a stated amount: a row priced at
1626.0125is schema-valid, and the model re-emitted it as1626.01with#unmapped_elementsempty and#errorsempty, because the element path is identical either way. That is the bug class §8.4b names — a path-difference diagnostic cannot see a changed value — applied to a number rather than to a flag. 8- RATIONAL_PRECISION =
Significant digits used when converting a Rational. The widest FA(3) numeric type is
TIlosciattotalDigits="22", so 30 clears every value the schema permits with room to spare — unlikeAMOUNT_SCALE + 10, which read as "12 decimal places" but means "12 significant digits" and truncated anything above ten billion. 30
Class Method Summary collapse
-
.amount(value) ⇒ String
A fixed-point decimal string with exactly AMOUNT_SCALE places.
-
.date(value) ⇒ String
ISO-8601 date, e.g.
-
.date_time(value) ⇒ String
Xsd:dateTime in UTC, e.g.
- .decimal(value) ⇒ Object
- .finite!(converted, value) ⇒ Object
-
.flag(value) ⇒ Object
The schema spells booleans as etd:TWybor1_2, where "1" is yes and "2" is no.
-
.integer(value) ⇒ Object
For the handful of elements restricting
xsd:integer—TypKorekty,NrWierszaFa. -
.quantity(value) ⇒ Object
Quantities allow more precision than amounts, so they keep their own scale rather than being rounded to two places — but they are not unbounded:
TIlosciisfractionDigits="6", so eight decimal places produce a schema-invalid document. -
.text(value) ⇒ Object
Canonicalises a value bound for an
xsd:tokenelement. -
.to_date(value) ⇒ Date
A String must be ISO-8601.
-
.unflag(value) ⇒ Object
The inverse of Formatting.flag, for the parser.
-
.unit_price(value) ⇒ String
A
TKwotowy2unit price.
Class Method Details
.amount(value) ⇒ String
Returns a fixed-point decimal string with exactly AMOUNT_SCALE places.
47 48 49 50 51 52 53 54 |
# File 'lib/ksef/fa3/formatting.rb', line 47 def amount(value) # `BigDecimal#to_s("F")` drops trailing zeros, so 1500 becomes "1500.0" — legal # xsd:decimal but wrong for a currency amount, and confusing to anyone reading # the invoice. Padding is done on the string rather than with format("%.2f"), # which would route the value through Float and defeat the point of BigDecimal. integer, fraction = decimal(value).round(AMOUNT_SCALE).to_s("F").split(".") "#{integer}.#{fraction.to_s.ljust(AMOUNT_SCALE, "0")[0, AMOUNT_SCALE]}" end |
.date(value) ⇒ String
Returns ISO-8601 date, e.g. "2026-08-22".
78 79 80 |
# File 'lib/ksef/fa3/formatting.rb', line 78 def date(value) to_date(value).strftime("%Y-%m-%d") end |
.date_time(value) ⇒ String
Returns xsd:dateTime in UTC, e.g. "2026-08-22T10:00:00Z".
108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/ksef/fa3/formatting.rb', line 108 def date_time(value) case value when String then value when DateTime then value.new_offset(0).strftime("%Y-%m-%dT%H:%M:%SZ") when Date then value.strftime("%Y-%m-%dT00:00:00Z") # Anything else is asked for `#utc`. An object that has none is a caller mistake, and # it must arrive as a ValidationError rather than as a bare NoMethodError from inside # the serializer — the same contract {.to_date} and {.decimal} keep. else utc_string(value) end end |
.decimal(value) ⇒ Object
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/ksef/fa3/formatting.rb', line 206 def decimal(value) converted = case value when BigDecimal then value # `BigDecimal("")` and `BigDecimal("abc")` raise a bare ArgumentError, which is not # part of this gem's hierarchy. Empty and malformed numeric text is exactly what a # rejected document contains, so it must arrive as a ValidationError. when Integer, String then strict_decimal(value) # A Rational needs an explicit precision. `RATIONAL_PRECISION` is *significant # digits*, not decimal places — passing AMOUNT_SCALE + 10 silently truncated large # amounts (12345678901234.56 became 12345678901200.0), which is the same class of # silent rounding the Float ban exists to prevent. when Rational then BigDecimal(value, RATIONAL_PRECISION) # `BigDecimal("NaN")` succeeds where `BigDecimal("abc")` raises, so a document # stating `<P_11>NaN</P_11>` reached the model, serialised as `NaN.00`, and broke # the `==`/`hash` contract on the way — two identical lines compared unequal while # hashing the same, which is the invariant the negative-zero rule exists to hold. when Float raise ValidationError, "Float is not allowed for monetary or quantity values (got #{value.inspect}). " \ "Use a BigDecimal, an Integer, or a decimal String." else raise ValidationError, "Cannot convert #{value.class} to a decimal: #{value.inspect}" end # **Negative zero is collapsed to zero**, and that is a `#hash` fix rather than a # cosmetic one. `BigDecimal("-0.00") == BigDecimal("0.00")` is true while their # hashes differ, so a line whose net came out of an ERP as `"-0.00"` — a routine # printf artifact on a zero-delta correction row — compared equal to its positive # twin and yet failed as a Hash key. That breaks the `==`/`hash` contract in exactly # the way {Issue} documents avoiding. The two are the same amount of money, so the # model keeps one representation of it. # NaN and Infinity are not amounts, and they arrive through the *String* door: # `BigDecimal("NaN")` succeeds, so a document stating `<P_11>NaN</P_11>` reached the # model and serialised as `NaN.00`. It also breaks the contract the rule below # exists to hold — `NaN != NaN`, so two identical lines compared unequal while # hashing the same. Refused for the same reason a Float is. finite!(converted, value) converted.zero? ? BigDecimal(0) : converted end |
.finite!(converted, value) ⇒ Object
247 248 249 250 251 252 253 |
# File 'lib/ksef/fa3/formatting.rb', line 247 def finite!(converted, value) return if converted.finite? raise ValidationError, "#{converted.nan? ? "NaN" : "Infinity"} is not a monetary or quantity value " \ "(got #{value.inspect})." end |
.flag(value) ⇒ Object
The schema spells booleans as etd:TWybor1_2, where "1" is yes and "2" is no. Emitting "true" or "0" would be schema-invalid, and the inversion is easy to get backwards, so it lives here rather than at each call site.
123 124 125 126 127 128 129 |
# File 'lib/ksef/fa3/formatting.rb', line 123 def flag(value) case value when true, "1", 1 then "1" when false, nil, "2", 2 then "2" else raise ValidationError, "Expected a boolean-ish value for a 1/2 flag, got #{value.inspect}" end end |
.integer(value) ⇒ Object
For the handful of elements restricting xsd:integer — TypKorekty, NrWierszaFa.
Integer() rather than #to_i, which answers 0 for "abc" and would turn a
malformed document into a plausible one. The failure arrives as a ValidationError
for the same reason to_date and decimal do: a caller rescuing this gem's own
hierarchy has to catch it.
Base 10 is explicit, and the reason is a shipped bug. Integer("010") is eight
— Ruby honours a leading zero as octal, 0x as hex and 0b as binary. NrWierszaFa
is TNaturalny, whose lexical space permits leading zeros, so <NrWierszaFa>010
meant ten, parsed as eight, and re-serialised as 8: a silently renumbered row on a
schema-valid document, in a field that is the only thing pairing a correction's
before/after rows. "08" failed the other way, raising on a document the schema
accepts. Found by audit 2026-08-24 (docs/REFERENCE.md §8.4b).
A Float is refused rather than truncated: Integer(2.9) is 2, and silently
changing a caller's value is the thing this method exists to prevent.
162 163 164 165 166 167 168 |
# File 'lib/ksef/fa3/formatting.rb', line 162 def integer(value) raise ValidationError, "Float is not allowed for a whole number (got #{value.inspect})" if value.is_a?(Float) value.is_a?(String) ? Integer(value, 10) : Integer(value) rescue ArgumentError, TypeError raise ValidationError, "Cannot read #{value.inspect} as a whole number" end |
.quantity(value) ⇒ Object
Quantities allow more precision than amounts, so they keep their own scale rather
than being rounded to two places — but they are not unbounded: TIlosci is
fractionDigits="6", so eight decimal places produce a schema-invalid document.
70 71 72 73 74 75 |
# File 'lib/ksef/fa3/formatting.rb', line 70 def quantity(value) formatted = decimal(value).round(QUANTITY_SCALE).to_s("F") # `to_s("F")` gives "10.0"; the schema is happy either way, but trailing ".0" # reads oddly on a count of hours. formatted.sub(/\.0\z/, "") end |
.text(value) ⇒ Object
Canonicalises a value bound for an xsd:token element.
Two things happen here, and both are the §8.2b rule — the model stores the document's representation, not the caller's input:
#to_s. TZnakowy and friends are string types, so an ERP passing number: 123
or vat_rate: 23 describes the same document as one passing "123" / "23". Left
raw, the two produced unequal invoices and DESIGN.md §7.6's round-trip law failed on
a field whose value never changed.
Whitespace collapse. xsd:token has whiteSpace="collapse", so the schema sees
— and KSeF stores — the collapsed value. Ksef::FA3::FieldChecks already measured lengths
against the collapsed form; the consumers did not, so vat_rate: " 23 " passed
tier 1 and then made VatRate.bucket's exact lookup raise, and a schema-valid
<P_12> 23 </P_12> parsed into an invoice that could not be re-serialised at all.
Text that is tagged UTF-8 and is not passes through untouched: gsub and strip
raise Encoding::CompatibilityError on such a value, which is outside this gem's
hierarchy, and Ksef::FA3::FieldChecks#encoding_issue is what reports it. Canonicalising is not
the place to refuse it — Invoice#errors is documented to answer rather than raise.
190 191 192 193 194 195 196 197 198 199 200 201 |
# File 'lib/ksef/fa3/formatting.rb', line 190 def text(value) return nil if value.nil? string = value.to_s # Returned untouched when it is not UTF-8 this gem can read — the collapse below is # a UTF-8 regexp and raises `Encoding::CompatibilityError` against anything else. # {FieldChecks.encoding_issue} is what reports it, so the value survives to be # complained about rather than exploding here (§15.1). return string unless FieldChecks.utf8?(string) string.gsub(FieldChecks::COLLAPSE, " ").strip end |
.to_date(value) ⇒ Date
A String must be ISO-8601. Date.parse guesses, and its guesses are worse than a
refusal: "junk" reads as June and answers the first of that month in the current
year, and "12" answers the twelfth of the current month — a value that changes with
the clock. Every date in an FA(3) document is etd:TData, i.e. xsd:date, so the
strict reading is also the only one a document can contain.
92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/ksef/fa3/formatting.rb', line 92 def to_date(value) return Date.iso8601(value) if value.is_a?(String) # `instance_of?`, not `is_a?`: a DateTime *is* a Date, but it carries a time of day, and # leaving one in place makes every date comparison depend on the clock — `DateTime.now # + 1` sorts after `Date.today + 1`, so a one-day tolerance quietly shrinks by however # late in the day it is. return value if value.instance_of?(Date) value.to_date # `Date::Error` is itself an `ArgumentError`, so listing both would shadow it. # `NoMethodError` covers an object with no `#to_date`. rescue ArgumentError, TypeError, NoMethodError => e raise ValidationError, "Cannot read #{value.inspect} as a date: #{e.}" end |
.unflag(value) ⇒ Object
The inverse of flag, for the parser.
nil reads as false, which is not laxity: for JST and GV the seller has no
such element at all, and a buyer that omits them means "no". Anything else raises,
because a third value in a 1/2 field is a document we do not understand rather
than one we should guess at.
137 138 139 140 141 142 143 |
# File 'lib/ksef/fa3/formatting.rb', line 137 def unflag(value) case value when "1", 1, true then true when "2", 2, nil, false then false else raise ValidationError, "Expected a 1/2 flag, got #{value.inspect}" end end |
.unit_price(value) ⇒ String
A TKwotowy2 unit price. Padded to AMOUNT_SCALE like any other money figure, so
150 still reads 150.00, but keeping up to UNIT_PRICE_SCALE places when the
value has them — TKwotowy2's pattern is (\.\d{1,8})?, so one to eight places
are all legal and every existing document is byte-identical under this.
62 63 64 65 |
# File 'lib/ksef/fa3/formatting.rb', line 62 def unit_price(value) integer, fraction = decimal(value).round(UNIT_PRICE_SCALE).to_s("F").split(".") "#{integer}.#{fraction.to_s.sub(/0+\z/, "").ljust(AMOUNT_SCALE, "0")}" end |