@open-nav/core 0.1.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.
Files changed (133) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +132 -0
  3. package/dist/constants.d.ts +49 -0
  4. package/dist/constants.d.ts.map +1 -0
  5. package/dist/constants.js +58 -0
  6. package/dist/constants.js.map +1 -0
  7. package/dist/crypto/hash.d.ts +16 -0
  8. package/dist/crypto/hash.d.ts.map +1 -0
  9. package/dist/crypto/hash.js +30 -0
  10. package/dist/crypto/hash.js.map +1 -0
  11. package/dist/crypto/node-provider.d.ts +9 -0
  12. package/dist/crypto/node-provider.d.ts.map +1 -0
  13. package/dist/crypto/node-provider.js +18 -0
  14. package/dist/crypto/node-provider.js.map +1 -0
  15. package/dist/crypto/provider.d.ts +28 -0
  16. package/dist/crypto/provider.d.ts.map +1 -0
  17. package/dist/crypto/provider.js +14 -0
  18. package/dist/crypto/provider.js.map +1 -0
  19. package/dist/crypto/request-id.d.ts +16 -0
  20. package/dist/crypto/request-id.d.ts.map +1 -0
  21. package/dist/crypto/request-id.js +59 -0
  22. package/dist/crypto/request-id.js.map +1 -0
  23. package/dist/crypto/signature.d.ts +40 -0
  24. package/dist/crypto/signature.d.ts.map +1 -0
  25. package/dist/crypto/signature.js +38 -0
  26. package/dist/crypto/signature.js.map +1 -0
  27. package/dist/crypto/token.d.ts +14 -0
  28. package/dist/crypto/token.d.ts.map +1 -0
  29. package/dist/crypto/token.js +82 -0
  30. package/dist/crypto/token.js.map +1 -0
  31. package/dist/crypto/web-provider.d.ts +18 -0
  32. package/dist/crypto/web-provider.d.ts.map +1 -0
  33. package/dist/crypto/web-provider.js +31 -0
  34. package/dist/crypto/web-provider.js.map +1 -0
  35. package/dist/errors.d.ts +75 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +71 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/generated/fault-codes.d.ts +1343 -0
  40. package/dist/generated/fault-codes.d.ts.map +1 -0
  41. package/dist/generated/fault-codes.js +1331 -0
  42. package/dist/generated/fault-codes.js.map +1 -0
  43. package/dist/generated/index.d.ts +4 -0
  44. package/dist/generated/index.d.ts.map +1 -0
  45. package/dist/generated/index.js +5 -0
  46. package/dist/generated/index.js.map +1 -0
  47. package/dist/generated/schema.d.ts +13 -0
  48. package/dist/generated/schema.d.ts.map +1 -0
  49. package/dist/generated/schema.js +3756 -0
  50. package/dist/generated/schema.js.map +1 -0
  51. package/dist/generated/types.d.ts +5580 -0
  52. package/dist/generated/types.d.ts.map +1 -0
  53. package/dist/generated/types.js +6 -0
  54. package/dist/generated/types.js.map +1 -0
  55. package/dist/index.d.ts +23 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +23 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/invoice/payload.d.ts +40 -0
  60. package/dist/invoice/payload.d.ts.map +1 -0
  61. package/dist/invoice/payload.js +66 -0
  62. package/dist/invoice/payload.js.map +1 -0
  63. package/dist/money/decimal.d.ts +69 -0
  64. package/dist/money/decimal.d.ts.map +1 -0
  65. package/dist/money/decimal.js +190 -0
  66. package/dist/money/decimal.js.map +1 -0
  67. package/dist/money/summary.d.ts +60 -0
  68. package/dist/money/summary.d.ts.map +1 -0
  69. package/dist/money/summary.js +236 -0
  70. package/dist/money/summary.js.map +1 -0
  71. package/dist/time.d.ts +16 -0
  72. package/dist/time.d.ts.map +1 -0
  73. package/dist/time.js +41 -0
  74. package/dist/time.js.map +1 -0
  75. package/dist/validation/issue.d.ts +67 -0
  76. package/dist/validation/issue.d.ts.map +1 -0
  77. package/dist/validation/issue.js +40 -0
  78. package/dist/validation/issue.js.map +1 -0
  79. package/dist/validation/rules.d.ts +34 -0
  80. package/dist/validation/rules.d.ts.map +1 -0
  81. package/dist/validation/rules.js +290 -0
  82. package/dist/validation/rules.js.map +1 -0
  83. package/dist/validation/schema.d.ts +14 -0
  84. package/dist/validation/schema.d.ts.map +1 -0
  85. package/dist/validation/schema.js +231 -0
  86. package/dist/validation/schema.js.map +1 -0
  87. package/dist/validation/tax-number.d.ts +39 -0
  88. package/dist/validation/tax-number.d.ts.map +1 -0
  89. package/dist/validation/tax-number.js +79 -0
  90. package/dist/validation/tax-number.js.map +1 -0
  91. package/dist/validation/validate.d.ts +29 -0
  92. package/dist/validation/validate.d.ts.map +1 -0
  93. package/dist/validation/validate.js +33 -0
  94. package/dist/validation/validate.js.map +1 -0
  95. package/dist/xml/descriptor.d.ts +82 -0
  96. package/dist/xml/descriptor.d.ts.map +1 -0
  97. package/dist/xml/descriptor.js +32 -0
  98. package/dist/xml/descriptor.js.map +1 -0
  99. package/dist/xml/read.d.ts +25 -0
  100. package/dist/xml/read.d.ts.map +1 -0
  101. package/dist/xml/read.js +230 -0
  102. package/dist/xml/read.js.map +1 -0
  103. package/dist/xml/write.d.ts +28 -0
  104. package/dist/xml/write.d.ts.map +1 -0
  105. package/dist/xml/write.js +299 -0
  106. package/dist/xml/write.js.map +1 -0
  107. package/package.json +55 -0
  108. package/src/constants.ts +76 -0
  109. package/src/crypto/hash.ts +35 -0
  110. package/src/crypto/node-provider.ts +19 -0
  111. package/src/crypto/provider.ts +37 -0
  112. package/src/crypto/request-id.ts +63 -0
  113. package/src/crypto/signature.ts +58 -0
  114. package/src/crypto/token.ts +86 -0
  115. package/src/crypto/web-provider.ts +32 -0
  116. package/src/errors.ts +109 -0
  117. package/src/generated/fault-codes.ts +1338 -0
  118. package/src/generated/index.ts +4 -0
  119. package/src/generated/schema.ts +3760 -0
  120. package/src/generated/types.ts +5829 -0
  121. package/src/index.ts +22 -0
  122. package/src/invoice/payload.ts +102 -0
  123. package/src/money/decimal.ts +218 -0
  124. package/src/money/summary.ts +380 -0
  125. package/src/time.ts +44 -0
  126. package/src/validation/issue.ts +102 -0
  127. package/src/validation/rules.ts +548 -0
  128. package/src/validation/schema.ts +315 -0
  129. package/src/validation/tax-number.ts +96 -0
  130. package/src/validation/validate.ts +45 -0
  131. package/src/xml/descriptor.ts +112 -0
  132. package/src/xml/read.ts +303 -0
  133. package/src/xml/write.ts +391 -0
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export * from './constants.js';
2
+ export * from './errors.js';
3
+ export * from './time.js';
4
+ export * from './crypto/provider.js';
5
+ export * from './crypto/node-provider.js';
6
+ export * from './crypto/web-provider.js';
7
+ export * from './crypto/hash.js';
8
+ export * from './crypto/signature.js';
9
+ export * from './crypto/token.js';
10
+ export * from './crypto/request-id.js';
11
+ export * from './xml/descriptor.js';
12
+ export * from './xml/read.js';
13
+ export * from './xml/write.js';
14
+ export * from './invoice/payload.js';
15
+ export * from './money/decimal.js';
16
+ export * from './money/summary.js';
17
+ export * from './validation/issue.js';
18
+ export * from './validation/tax-number.js';
19
+ export * from './validation/schema.js';
20
+ export * from './validation/rules.js';
21
+ export * from './validation/validate.js';
22
+ export * from './generated/index.js';
@@ -0,0 +1,102 @@
1
+ import { gunzipSync, gzipSync } from 'node:zlib';
2
+ import { NavValidationError } from '../errors.js';
3
+ import type { InvoiceAnnulment, InvoiceData } from '../generated/types.js';
4
+ import { parseDocumentAs } from '../xml/read.js';
5
+ import { serializeDocument, type SerializeOptions } from '../xml/write.js';
6
+
7
+ /** Gzip magic bytes, used to detect compression regardless of what NAV claims. */
8
+ const GZIP_MAGIC = [0x1f, 0x8b];
9
+
10
+ export interface EncodeOptions extends SerializeOptions {
11
+ /**
12
+ * Gzip the XML before base64 encoding.
13
+ *
14
+ * NAV allows this for `manageInvoice` and it materially reduces payload
15
+ * size, but the whole batch must then set `compressedContent` — the flag is
16
+ * per request, not per invoice.
17
+ */
18
+ compress?: boolean;
19
+ }
20
+
21
+ /**
22
+ * Serialise an invoice to the base64 form carried in `invoiceData`.
23
+ *
24
+ * The returned string is exactly what must be hashed for the request
25
+ * signature, which is why encoding and hashing must never be done from two
26
+ * separately serialised copies of the same invoice.
27
+ */
28
+ export function encodeInvoiceData(invoice: InvoiceData, options: EncodeOptions = {}): string {
29
+ return encodeDocument('InvoiceData', invoice, options);
30
+ }
31
+
32
+ /** Serialise an annulment to the base64 form carried in `invoiceAnnulment`. */
33
+ export function encodeInvoiceAnnulment(
34
+ annulment: InvoiceAnnulment,
35
+ options: EncodeOptions = {},
36
+ ): string {
37
+ return encodeDocument('InvoiceAnnulment', annulment, options);
38
+ }
39
+
40
+ function encodeDocument(root: string, value: unknown, options: EncodeOptions): string {
41
+ const { compress, ...serializeOptions } = options;
42
+ const xml = serializeDocument(root, value, serializeOptions);
43
+ const bytes = Buffer.from(xml, 'utf8');
44
+ return (compress ? gzipSync(bytes) : bytes).toString('base64');
45
+ }
46
+
47
+ export interface DecodeOptions {
48
+ /**
49
+ * Whether the payload is gzipped, normally from NAV's
50
+ * `compressedContentIndicator`.
51
+ *
52
+ * Leave it unset to detect compression from the payload itself, which is
53
+ * more robust: the indicator describes the batch as submitted, and reading
54
+ * it wrongly turns a valid invoice into an XML parse error.
55
+ */
56
+ compressed?: boolean;
57
+ }
58
+
59
+ /** Decode a base64 `invoiceData` payload from a NAV query response. */
60
+ export function decodeInvoiceData(base64: string, options: DecodeOptions = {}): InvoiceData {
61
+ return parseDocumentAs<InvoiceData>(decodeToXml(base64, options), 'InvoiceData', {
62
+ unknownElements: 'ignore',
63
+ });
64
+ }
65
+
66
+ /** Decode a base64 `invoiceAnnulment` payload. */
67
+ export function decodeInvoiceAnnulment(
68
+ base64: string,
69
+ options: DecodeOptions = {},
70
+ ): InvoiceAnnulment {
71
+ return parseDocumentAs<InvoiceAnnulment>(decodeToXml(base64, options), 'InvoiceAnnulment', {
72
+ unknownElements: 'ignore',
73
+ });
74
+ }
75
+
76
+ /** Decode a base64 payload to its XML text, decompressing when needed. */
77
+ export function decodeToXml(base64: string, options: DecodeOptions = {}): string {
78
+ const bytes = Buffer.from(base64.replace(/\s+/g, ''), 'base64');
79
+ if (bytes.length === 0) {
80
+ throw new NavValidationError('Empty base64 payload', [
81
+ { path: '', code: 'EMPTY_PAYLOAD', message: 'decoded to zero bytes' },
82
+ ]);
83
+ }
84
+
85
+ const looksGzipped = bytes[0] === GZIP_MAGIC[0] && bytes[1] === GZIP_MAGIC[1];
86
+ const compressed = options.compressed ?? looksGzipped;
87
+ if (!compressed) return bytes.toString('utf8');
88
+
89
+ try {
90
+ return gunzipSync(bytes).toString('utf8');
91
+ } catch (cause) {
92
+ throw new NavValidationError('Could not decompress payload', [
93
+ {
94
+ path: '',
95
+ code: 'DECOMPRESSION_FAILED',
96
+ message: looksGzipped
97
+ ? (cause as Error).message
98
+ : 'was declared compressed but does not start with the gzip magic bytes',
99
+ },
100
+ ]);
101
+ }
102
+ }
@@ -0,0 +1,218 @@
1
+ import { NavValidationError } from '../errors.js';
2
+
3
+ /**
4
+ * How to resolve a value exactly halfway between two representable ones.
5
+ *
6
+ * `half-up` rounds away from zero, which is what Hungarian commercial
7
+ * practice and NAV's examples use, and is the default everywhere here.
8
+ */
9
+ export type RoundingMode = 'half-up' | 'half-even' | 'down' | 'up';
10
+
11
+ const DECIMAL_PATTERN = /^([+-]?)(\d+)(?:\.(\d*))?$/;
12
+
13
+ /**
14
+ * An exact decimal, held as a scaled integer.
15
+ *
16
+ * The NAV schemas make binary floating point untenable: `MonetaryType`
17
+ * allows 18 significant digits, `QuantityType` allows 22 with 10 decimal
18
+ * places, and a VAT rate of `0.27` has no exact `double` representation.
19
+ * Since NAV rejects a batch whose lines fail to reconcile with its summary,
20
+ * every amount is carried through as an exact value and rounded only where
21
+ * the rules say to round.
22
+ */
23
+ export class Decimal {
24
+ /** Value as an integer, scaled by 10 ** scale. */
25
+ readonly units: bigint;
26
+ /** Number of decimal places. */
27
+ readonly scale: number;
28
+
29
+ private constructor(units: bigint, scale: number) {
30
+ this.units = units;
31
+ this.scale = scale;
32
+ }
33
+
34
+ static readonly ZERO = new Decimal(0n, 0);
35
+ static readonly ONE = new Decimal(1n, 0);
36
+
37
+ /**
38
+ * Build a decimal from an exact representation.
39
+ *
40
+ * Numbers are accepted for convenience but only when they are integers or
41
+ * survive a round trip through their shortest decimal form; anything else
42
+ * has already lost precision and is rejected rather than silently carried
43
+ * forward.
44
+ */
45
+ static from(value: Decimal | string | number | bigint): Decimal {
46
+ if (value instanceof Decimal) return value;
47
+ if (typeof value === 'bigint') return new Decimal(value, 0);
48
+ if (typeof value === 'number') {
49
+ if (!Number.isFinite(value)) {
50
+ throw invalid(String(value), 'is not a finite number');
51
+ }
52
+ return Decimal.parse(String(value));
53
+ }
54
+ return Decimal.parse(value);
55
+ }
56
+
57
+ private static parse(text: string): Decimal {
58
+ const match = DECIMAL_PATTERN.exec(text.trim());
59
+ if (!match) throw invalid(text, 'is not a decimal number');
60
+ const [, sign, whole, fraction = ''] = match;
61
+ const units = BigInt(`${whole}${fraction}`) * (sign === '-' ? -1n : 1n);
62
+ return new Decimal(units, fraction.length);
63
+ }
64
+
65
+ /** Re-express with a different number of decimal places. */
66
+ rescale(scale: number, mode: RoundingMode = 'half-up'): Decimal {
67
+ if (scale === this.scale) return this;
68
+ if (scale > this.scale) {
69
+ return new Decimal(this.units * 10n ** BigInt(scale - this.scale), scale);
70
+ }
71
+ const divisor = 10n ** BigInt(this.scale - scale);
72
+ return new Decimal(divideRounded(this.units, divisor, mode), scale);
73
+ }
74
+
75
+ /** Round to `scale` decimal places. */
76
+ round(scale: number, mode: RoundingMode = 'half-up'): Decimal {
77
+ return this.rescale(scale, mode);
78
+ }
79
+
80
+ add(other: Decimal | string | number): Decimal {
81
+ const [left, right, scale] = align(this, Decimal.from(other));
82
+ return new Decimal(left + right, scale);
83
+ }
84
+
85
+ subtract(other: Decimal | string | number): Decimal {
86
+ const [left, right, scale] = align(this, Decimal.from(other));
87
+ return new Decimal(left - right, scale);
88
+ }
89
+
90
+ multiply(other: Decimal | string | number): Decimal {
91
+ const right = Decimal.from(other);
92
+ return new Decimal(this.units * right.units, this.scale + right.scale);
93
+ }
94
+
95
+ /**
96
+ * Divide, rounding to `scale` decimal places.
97
+ *
98
+ * Division cannot be exact, so the scale is explicit rather than inferred:
99
+ * an accidental default would be a silent precision decision.
100
+ */
101
+ divide(other: Decimal | string | number, scale: number, mode: RoundingMode = 'half-up'): Decimal {
102
+ const right = Decimal.from(other);
103
+ if (right.units === 0n) throw invalid('0', 'cannot be used as a divisor');
104
+ // Scale the numerator so the integer division lands at the target scale.
105
+ const shift = BigInt(scale) + BigInt(right.scale) - BigInt(this.scale);
106
+ const numerator = shift >= 0n ? this.units * 10n ** shift : this.units;
107
+ const denominator = shift >= 0n ? right.units : right.units * 10n ** -shift;
108
+ return new Decimal(divideRounded(numerator, denominator, mode), scale);
109
+ }
110
+
111
+ negate(): Decimal {
112
+ return new Decimal(-this.units, this.scale);
113
+ }
114
+
115
+ abs(): Decimal {
116
+ return this.units < 0n ? this.negate() : this;
117
+ }
118
+
119
+ /** -1, 0 or 1. */
120
+ compare(other: Decimal | string | number): -1 | 0 | 1 {
121
+ const [left, right] = align(this, Decimal.from(other));
122
+ return left < right ? -1 : left > right ? 1 : 0;
123
+ }
124
+
125
+ equals(other: Decimal | string | number): boolean {
126
+ return this.compare(other) === 0;
127
+ }
128
+
129
+ isZero(): boolean {
130
+ return this.units === 0n;
131
+ }
132
+
133
+ isNegative(): boolean {
134
+ return this.units < 0n;
135
+ }
136
+
137
+ /** Number of significant digits, for checking `totalDigits` facets. */
138
+ totalDigits(): number {
139
+ const digits = (this.units < 0n ? -this.units : this.units).toString();
140
+ return digits === '0' ? 1 : digits.length;
141
+ }
142
+
143
+ /** Canonical decimal text, as written into the XML. */
144
+ toString(): string {
145
+ const negative = this.units < 0n;
146
+ const digits = (negative ? -this.units : this.units).toString();
147
+ if (this.scale === 0) return `${negative ? '-' : ''}${digits}`;
148
+ const padded = digits.padStart(this.scale + 1, '0');
149
+ const whole = padded.slice(0, padded.length - this.scale);
150
+ const fraction = padded.slice(padded.length - this.scale);
151
+ return `${negative ? '-' : ''}${whole}.${fraction}`;
152
+ }
153
+
154
+ /** Fixed-point text with exactly `scale` decimals. */
155
+ toFixed(scale: number, mode: RoundingMode = 'half-up'): string {
156
+ return this.rescale(scale, mode).toString();
157
+ }
158
+
159
+ /** Lossy; only for display and diagnostics. */
160
+ toNumber(): number {
161
+ return Number(this.toString());
162
+ }
163
+
164
+ toJSON(): string {
165
+ return this.toString();
166
+ }
167
+ }
168
+
169
+ /** Sum a list exactly, with no intermediate rounding. */
170
+ export function sum(values: Array<Decimal | string | number>): Decimal {
171
+ return values.reduce<Decimal>((total, value) => total.add(Decimal.from(value)), Decimal.ZERO);
172
+ }
173
+
174
+ function align(left: Decimal, right: Decimal): [bigint, bigint, number] {
175
+ if (left.scale === right.scale) return [left.units, right.units, left.scale];
176
+ const scale = Math.max(left.scale, right.scale);
177
+ return [
178
+ left.units * 10n ** BigInt(scale - left.scale),
179
+ right.units * 10n ** BigInt(scale - right.scale),
180
+ scale,
181
+ ];
182
+ }
183
+
184
+ function divideRounded(numerator: bigint, denominator: bigint, mode: RoundingMode): bigint {
185
+ const negative = numerator < 0n !== denominator < 0n;
186
+ const absNumerator = numerator < 0n ? -numerator : numerator;
187
+ const absDenominator = denominator < 0n ? -denominator : denominator;
188
+ const quotient = absNumerator / absDenominator;
189
+ const remainder = absNumerator % absDenominator;
190
+
191
+ let rounded = quotient;
192
+ if (remainder !== 0n) {
193
+ const twice = remainder * 2n;
194
+ switch (mode) {
195
+ case 'down':
196
+ break;
197
+ case 'up':
198
+ rounded += 1n;
199
+ break;
200
+ case 'half-even':
201
+ if (twice > absDenominator || (twice === absDenominator && quotient % 2n === 1n)) {
202
+ rounded += 1n;
203
+ }
204
+ break;
205
+ default:
206
+ // half-up: ties round away from zero.
207
+ if (twice >= absDenominator) rounded += 1n;
208
+ }
209
+ }
210
+
211
+ return negative ? -rounded : rounded;
212
+ }
213
+
214
+ function invalid(value: string, message: string): NavValidationError {
215
+ return new NavValidationError('Invalid decimal', [
216
+ { path: '', code: 'INVALID_DECIMAL', message: `${JSON.stringify(value)} ${message}` },
217
+ ]);
218
+ }