@fast-white-cat/integration-ksef-direct 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 (140) hide show
  1. package/README.md +36 -0
  2. package/dist/index.js +2 -0
  3. package/dist/index.js.map +7 -0
  4. package/dist/modules/integration_ksef_direct/acl.js +13 -0
  5. package/dist/modules/integration_ksef_direct/acl.js.map +7 -0
  6. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents/[id].js +92 -0
  7. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents/[id].js.map +7 -0
  8. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents.js +105 -0
  9. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents.js.map +7 -0
  10. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/health.js +158 -0
  11. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/health.js.map +7 -0
  12. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents/[id].js +86 -0
  13. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents/[id].js.map +7 -0
  14. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents.js +112 -0
  15. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents.js.map +7 -0
  16. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/seller-info.js +54 -0
  17. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/seller-info.js.map +7 -0
  18. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents/[id]/send.js +64 -0
  19. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents/[id]/send.js.map +7 -0
  20. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents.js +104 -0
  21. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents.js.map +7 -0
  22. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/invoice-numbers.js +41 -0
  23. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/invoice-numbers.js.map +7 -0
  24. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/fetch.js +172 -0
  25. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/fetch.js.map +7 -0
  26. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/sync.js +80 -0
  27. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/sync.js.map +7 -0
  28. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.js +441 -0
  29. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.js.map +7 -0
  30. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.meta.js +8 -0
  31. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.meta.js.map +7 -0
  32. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/page.js +193 -0
  33. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/page.js.map +7 -0
  34. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/received-documents/page.js +314 -0
  35. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/received-documents/page.js.map +7 -0
  36. package/dist/modules/integration_ksef_direct/backend/page.js +154 -0
  37. package/dist/modules/integration_ksef_direct/backend/page.js.map +7 -0
  38. package/dist/modules/integration_ksef_direct/commands/create-ksef-direct-document.js +80 -0
  39. package/dist/modules/integration_ksef_direct/commands/create-ksef-direct-document.js.map +7 -0
  40. package/dist/modules/integration_ksef_direct/commands/enqueue-ksef-direct-document.js +43 -0
  41. package/dist/modules/integration_ksef_direct/commands/enqueue-ksef-direct-document.js.map +7 -0
  42. package/dist/modules/integration_ksef_direct/data/entities.js +224 -0
  43. package/dist/modules/integration_ksef_direct/data/entities.js.map +7 -0
  44. package/dist/modules/integration_ksef_direct/data/validators.js +103 -0
  45. package/dist/modules/integration_ksef_direct/data/validators.js.map +7 -0
  46. package/dist/modules/integration_ksef_direct/di.js +11 -0
  47. package/dist/modules/integration_ksef_direct/di.js.map +7 -0
  48. package/dist/modules/integration_ksef_direct/events.js +21 -0
  49. package/dist/modules/integration_ksef_direct/events.js.map +7 -0
  50. package/dist/modules/integration_ksef_direct/index.js +10 -0
  51. package/dist/modules/integration_ksef_direct/index.js.map +7 -0
  52. package/dist/modules/integration_ksef_direct/integration.js +56 -0
  53. package/dist/modules/integration_ksef_direct/integration.js.map +7 -0
  54. package/dist/modules/integration_ksef_direct/lib/health.js +32 -0
  55. package/dist/modules/integration_ksef_direct/lib/health.js.map +7 -0
  56. package/dist/modules/integration_ksef_direct/lib/invoiceNumberFormat.js +23 -0
  57. package/dist/modules/integration_ksef_direct/lib/invoiceNumberFormat.js.map +7 -0
  58. package/dist/modules/integration_ksef_direct/lib/ksefClient.js +523 -0
  59. package/dist/modules/integration_ksef_direct/lib/ksefClient.js.map +7 -0
  60. package/dist/modules/integration_ksef_direct/lib/ksefCrypto.js +103 -0
  61. package/dist/modules/integration_ksef_direct/lib/ksefCrypto.js.map +7 -0
  62. package/dist/modules/integration_ksef_direct/lib/ksefFa2Xml.js +123 -0
  63. package/dist/modules/integration_ksef_direct/lib/ksefFa2Xml.js.map +7 -0
  64. package/dist/modules/integration_ksef_direct/lib/ksefXmlParser.js +76 -0
  65. package/dist/modules/integration_ksef_direct/lib/ksefXmlParser.js.map +7 -0
  66. package/dist/modules/integration_ksef_direct/migrations/Migration20260519210000_integration_ksef_direct.js +15 -0
  67. package/dist/modules/integration_ksef_direct/migrations/Migration20260519210000_integration_ksef_direct.js.map +7 -0
  68. package/dist/modules/integration_ksef_direct/migrations/Migration20260520120000_ksef_direct_documents.js +17 -0
  69. package/dist/modules/integration_ksef_direct/migrations/Migration20260520120000_ksef_direct_documents.js.map +7 -0
  70. package/dist/modules/integration_ksef_direct/migrations/Migration20260520220000_ksef_direct_send_queue.js +15 -0
  71. package/dist/modules/integration_ksef_direct/migrations/Migration20260520220000_ksef_direct_send_queue.js.map +7 -0
  72. package/dist/modules/integration_ksef_direct/migrations/Migration20260520230000_ksef_direct_seller_per_document.js +17 -0
  73. package/dist/modules/integration_ksef_direct/migrations/Migration20260520230000_ksef_direct_seller_per_document.js.map +7 -0
  74. package/dist/modules/integration_ksef_direct/migrations/Migration20260521120000_ksef_direct_received_documents.js +16 -0
  75. package/dist/modules/integration_ksef_direct/migrations/Migration20260521120000_ksef_direct_received_documents.js.map +7 -0
  76. package/dist/modules/integration_ksef_direct/migrations/Migration20260521130000_ksef_received_download_urls.js +15 -0
  77. package/dist/modules/integration_ksef_direct/migrations/Migration20260521130000_ksef_received_download_urls.js.map +7 -0
  78. package/dist/modules/integration_ksef_direct/setup.js +11 -0
  79. package/dist/modules/integration_ksef_direct/setup.js.map +7 -0
  80. package/dist/modules/integration_ksef_direct/subscribers/auto-enqueue-ksef-document.js +19 -0
  81. package/dist/modules/integration_ksef_direct/subscribers/auto-enqueue-ksef-document.js.map +7 -0
  82. package/dist/modules/integration_ksef_direct/workers/check-ksef-document-status.js +103 -0
  83. package/dist/modules/integration_ksef_direct/workers/check-ksef-document-status.js.map +7 -0
  84. package/dist/modules/integration_ksef_direct/workers/send-ksef-document.js +104 -0
  85. package/dist/modules/integration_ksef_direct/workers/send-ksef-document.js.map +7 -0
  86. package/dist/modules/integration_ksef_direct/workers/sync-received-documents.js +137 -0
  87. package/dist/modules/integration_ksef_direct/workers/sync-received-documents.js.map +7 -0
  88. package/dist/types/declarations.d.js +1 -0
  89. package/dist/types/declarations.d.js.map +7 -0
  90. package/package.json +98 -0
  91. package/src/index.ts +1 -0
  92. package/src/modules/integration_ksef_direct/__tests__/invoiceNumberFormat.test.ts +42 -0
  93. package/src/modules/integration_ksef_direct/__tests__/ksefFa2Xml.test.ts +407 -0
  94. package/src/modules/integration_ksef_direct/__tests__/ksefXmlParser.test.ts +230 -0
  95. package/src/modules/integration_ksef_direct/acl.ts +9 -0
  96. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents/[id].ts +94 -0
  97. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents.ts +111 -0
  98. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/health.ts +194 -0
  99. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents/[id].ts +88 -0
  100. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents.ts +119 -0
  101. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/seller-info.ts +62 -0
  102. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents/[id]/send.ts +64 -0
  103. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents.ts +109 -0
  104. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/invoice-numbers.ts +40 -0
  105. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/fetch.ts +185 -0
  106. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/sync.ts +86 -0
  107. package/src/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.meta.ts +4 -0
  108. package/src/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.tsx +470 -0
  109. package/src/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/page.tsx +233 -0
  110. package/src/modules/integration_ksef_direct/backend/integration-ksef-direct/received-documents/page.tsx +415 -0
  111. package/src/modules/integration_ksef_direct/backend/page.tsx +183 -0
  112. package/src/modules/integration_ksef_direct/commands/create-ksef-direct-document.ts +93 -0
  113. package/src/modules/integration_ksef_direct/commands/enqueue-ksef-direct-document.ts +57 -0
  114. package/src/modules/integration_ksef_direct/data/entities.ts +195 -0
  115. package/src/modules/integration_ksef_direct/data/validators.ts +115 -0
  116. package/src/modules/integration_ksef_direct/di.ts +9 -0
  117. package/src/modules/integration_ksef_direct/events.ts +18 -0
  118. package/src/modules/integration_ksef_direct/i18n/en.json +115 -0
  119. package/src/modules/integration_ksef_direct/i18n/pl.json +115 -0
  120. package/src/modules/integration_ksef_direct/index.ts +6 -0
  121. package/src/modules/integration_ksef_direct/integration.ts +54 -0
  122. package/src/modules/integration_ksef_direct/lib/health.ts +43 -0
  123. package/src/modules/integration_ksef_direct/lib/invoiceNumberFormat.ts +23 -0
  124. package/src/modules/integration_ksef_direct/lib/ksefClient.ts +668 -0
  125. package/src/modules/integration_ksef_direct/lib/ksefCrypto.ts +138 -0
  126. package/src/modules/integration_ksef_direct/lib/ksefFa2Xml.ts +147 -0
  127. package/src/modules/integration_ksef_direct/lib/ksefXmlParser.ts +97 -0
  128. package/src/modules/integration_ksef_direct/migrations/.snapshot-open-mercato.json +1028 -0
  129. package/src/modules/integration_ksef_direct/migrations/Migration20260519210000_integration_ksef_direct.ts +15 -0
  130. package/src/modules/integration_ksef_direct/migrations/Migration20260520120000_ksef_direct_documents.ts +17 -0
  131. package/src/modules/integration_ksef_direct/migrations/Migration20260520220000_ksef_direct_send_queue.ts +15 -0
  132. package/src/modules/integration_ksef_direct/migrations/Migration20260520230000_ksef_direct_seller_per_document.ts +17 -0
  133. package/src/modules/integration_ksef_direct/migrations/Migration20260521120000_ksef_direct_received_documents.ts +16 -0
  134. package/src/modules/integration_ksef_direct/migrations/Migration20260521130000_ksef_received_download_urls.ts +15 -0
  135. package/src/modules/integration_ksef_direct/setup.ts +9 -0
  136. package/src/modules/integration_ksef_direct/subscribers/auto-enqueue-ksef-document.ts +21 -0
  137. package/src/modules/integration_ksef_direct/workers/check-ksef-document-status.ts +129 -0
  138. package/src/modules/integration_ksef_direct/workers/send-ksef-document.ts +137 -0
  139. package/src/modules/integration_ksef_direct/workers/sync-received-documents.ts +171 -0
  140. package/src/types/declarations.d.ts +1 -0
@@ -0,0 +1,138 @@
1
+ import crypto from 'node:crypto'
2
+
3
+ type KsefEnvironment = 'test' | 'production'
4
+
5
+ const BASE_URLS: Record<KsefEnvironment, string> = {
6
+ test: 'https://api-test.ksef.mf.gov.pl/v2',
7
+ production: 'https://api.ksef.mf.gov.pl/v2',
8
+ }
9
+
10
+ interface PublicKeyCacheEntry {
11
+ publicKeyPem: string
12
+ fetchedAt: number
13
+ }
14
+
15
+ const PUBLIC_KEY_CACHE = new Map<KsefEnvironment, PublicKeyCacheEntry>()
16
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
17
+
18
+ interface PublicKeyCertificateEntry {
19
+ certificate: string
20
+ certificateId: string
21
+ publicKeyId?: string
22
+ validFrom?: string
23
+ validTo?: string
24
+ usage?: string[]
25
+ }
26
+
27
+ async function fetchCertificates(environment: KsefEnvironment): Promise<PublicKeyCertificateEntry[]> {
28
+ const url = `${BASE_URLS[environment]}/security/public-key-certificates`
29
+ const response = await fetch(url, {
30
+ method: 'GET',
31
+ headers: { 'Accept': 'application/json' },
32
+ signal: AbortSignal.timeout(10_000),
33
+ })
34
+ if (!response.ok) {
35
+ throw new Error(`Failed to fetch KSeF public key certificates: HTTP ${response.status}`)
36
+ }
37
+ const data = await response.json() as PublicKeyCertificateEntry[]
38
+ if (!Array.isArray(data)) {
39
+ throw new Error('KSeF public key response is not an array')
40
+ }
41
+ return data
42
+ }
43
+
44
+ function certToPem(entry: PublicKeyCertificateEntry): string {
45
+ const derBuffer = Buffer.from(entry.certificate, 'base64')
46
+ const x509 = new crypto.X509Certificate(derBuffer)
47
+ return x509.publicKey.export({ type: 'spki', format: 'pem' }).toString()
48
+ }
49
+
50
+ export async function fetchPublicKey(environment: KsefEnvironment): Promise<string> {
51
+ const cached = PUBLIC_KEY_CACHE.get(environment)
52
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
53
+ return cached.publicKeyPem
54
+ }
55
+
56
+ const certs = await fetchCertificates(environment)
57
+ const tokenCert = certs.find(entry => entry.usage?.includes('KsefTokenEncryption'))
58
+ if (!tokenCert?.certificate) {
59
+ throw new Error('KSeF public key response missing KsefTokenEncryption certificate')
60
+ }
61
+ const publicKeyPem = certToPem(tokenCert)
62
+ PUBLIC_KEY_CACHE.set(environment, { publicKeyPem, fetchedAt: Date.now() })
63
+ return publicKeyPem
64
+ }
65
+
66
+ export async function fetchInvoicePublicKey(
67
+ environment: KsefEnvironment,
68
+ ): Promise<{ publicKeyPem: string; publicKeyId?: string }> {
69
+ const certs = await fetchCertificates(environment)
70
+ const invoiceCert = certs.find(entry => entry.usage?.includes('SymmetricKeyEncryption'))
71
+ ?? certs.find(entry => entry.usage?.includes('KsefTokenEncryption'))
72
+ if (!invoiceCert?.certificate) {
73
+ throw new Error('KSeF public key response missing invoice encryption certificate')
74
+ }
75
+ return { publicKeyPem: certToPem(invoiceCert), publicKeyId: invoiceCert.publicKeyId }
76
+ }
77
+
78
+ export function clearPublicKeyCache(environment?: KsefEnvironment): void {
79
+ if (environment) {
80
+ PUBLIC_KEY_CACHE.delete(environment)
81
+ } else {
82
+ PUBLIC_KEY_CACHE.clear()
83
+ }
84
+ }
85
+
86
+ export function encryptKsefToken(token: string, timestampMs: number, publicKeyPem: string): string {
87
+ const plaintext = `${token}|${timestampMs}`
88
+ const encrypted = crypto.publicEncrypt(
89
+ {
90
+ key: publicKeyPem,
91
+ padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
92
+ oaepHash: 'sha256',
93
+ },
94
+ Buffer.from(plaintext, 'utf-8'),
95
+ )
96
+ return encrypted.toString('base64')
97
+ }
98
+
99
+ export function generateSymmetricKey(): { key: Buffer; iv: Buffer } {
100
+ return {
101
+ key: crypto.randomBytes(32),
102
+ iv: crypto.randomBytes(16),
103
+ }
104
+ }
105
+
106
+ export function prepareInvoicePayload(
107
+ xmlString: string,
108
+ publicKeyPem: string,
109
+ ): {
110
+ encryptedSymmetricKey: string
111
+ initializationVector: string
112
+ encryptedInvoiceContent: string
113
+ invoiceHash: string
114
+ invoiceSize: number
115
+ encryptedInvoiceHash: string
116
+ encryptedInvoiceSize: number
117
+ } {
118
+ const { key, iv } = generateSymmetricKey()
119
+ const xmlBytes = Buffer.from(xmlString, 'utf-8')
120
+
121
+ const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
122
+ const encryptedContent = Buffer.concat([cipher.update(xmlBytes), cipher.final()])
123
+
124
+ const encryptedSymmetricKey = crypto.publicEncrypt(
125
+ { key: publicKeyPem, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
126
+ key,
127
+ )
128
+
129
+ return {
130
+ encryptedSymmetricKey: encryptedSymmetricKey.toString('base64'),
131
+ initializationVector: iv.toString('base64'),
132
+ encryptedInvoiceContent: encryptedContent.toString('base64'),
133
+ invoiceHash: crypto.createHash('sha256').update(xmlBytes).digest('base64'),
134
+ invoiceSize: xmlBytes.length,
135
+ encryptedInvoiceHash: crypto.createHash('sha256').update(encryptedContent).digest('base64'),
136
+ encryptedInvoiceSize: encryptedContent.length,
137
+ }
138
+ }
@@ -0,0 +1,147 @@
1
+ import type { KsefDirectDocument } from '../data/entities'
2
+
3
+ export class KsefXmlGenerationError extends Error {
4
+ constructor(message: string) {
5
+ super(message)
6
+ this.name = 'KsefXmlGenerationError'
7
+ }
8
+ }
9
+
10
+ export type SellerInfo = {
11
+ sellerName: string
12
+ sellerAddressL1?: string
13
+ sellerCity?: string
14
+ sellerCountry?: string
15
+ }
16
+
17
+ function escapeXml(str: string): string {
18
+ return str
19
+ .replace(/&/g, '&amp;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&apos;')
24
+ }
25
+
26
+ function formatDate(date: Date): string {
27
+ return date.toISOString().split('T')[0]!
28
+ }
29
+
30
+ // vatRate → { suffix for P_13/P_14, p12Code for FaWiersz.P_12, whether VAT amount applies }
31
+ const VAT_RATE_GROUPS: Record<string, { suffix: string; p12Code: string; hasVat: boolean }> = {
32
+ '23': { suffix: '1', p12Code: '23', hasVat: true },
33
+ '8': { suffix: '2', p12Code: '8', hasVat: true },
34
+ '5': { suffix: '3', p12Code: '5', hasVat: true },
35
+ '0': { suffix: '4', p12Code: '0', hasVat: false },
36
+ 'ZW': { suffix: '5', p12Code: 'zw', hasVat: false },
37
+ 'NP': { suffix: '6', p12Code: 'np', hasVat: false },
38
+ }
39
+
40
+ export function generateFa2Xml(document: KsefDirectDocument, seller: SellerInfo): string {
41
+ if (!seller.sellerName.trim()) {
42
+ throw new KsefXmlGenerationError('sellerName is required for FA_VAT 2.0 XML generation')
43
+ }
44
+ if (!seller.sellerAddressL1?.trim()) {
45
+ throw new KsefXmlGenerationError('sellerAddressL1 is required for FA_VAT 2.0 XML generation')
46
+ }
47
+
48
+ const now = new Date()
49
+ const issueDate = formatDate(document.issueDate)
50
+ const saleDate = document.saleDate ? formatDate(document.saleDate) : issueDate
51
+
52
+ const lineItemsXml = document.lineItems.map((item, index) => {
53
+ const group = VAT_RATE_GROUPS[item.vatRate]
54
+ const p12 = group?.p12Code ?? item.vatRate
55
+ return `
56
+ <FaWiersz>
57
+ <NrWierszaFa>${index + 1}</NrWierszaFa>
58
+ <P_7>${escapeXml(item.description)}</P_7>
59
+ <P_8A>${escapeXml(item.unit)}</P_8A>
60
+ <P_8B>${item.quantity}</P_8B>
61
+ <P_9A>${item.unitNetPrice.toFixed(2)}</P_9A>
62
+ <P_11>${item.netAmount.toFixed(2)}</P_11>
63
+ <P_12>${escapeXml(p12)}</P_12>
64
+ </FaWiersz>`
65
+ }).join('')
66
+
67
+ // Group by VAT rate, accumulate net + vat amounts
68
+ const groups = new Map<string, { net: number; vat: number; hasVat: boolean }>()
69
+ for (const item of document.lineItems) {
70
+ const group = VAT_RATE_GROUPS[item.vatRate]
71
+ if (!group) continue
72
+ const existing = groups.get(group.suffix) ?? { net: 0, vat: 0, hasVat: group.hasVat }
73
+ groups.set(group.suffix, {
74
+ net: existing.net + item.netAmount,
75
+ vat: existing.vat + item.vatAmount,
76
+ hasVat: group.hasVat,
77
+ })
78
+ }
79
+
80
+ // Sort suffix numerically for deterministic output
81
+ const sortedSuffixes = [...groups.keys()].sort()
82
+ const vatSummaryXml = sortedSuffixes.map((suffix) => {
83
+ const g = groups.get(suffix)!
84
+ const net = `\n <P_13_${suffix}>${g.net.toFixed(2)}</P_13_${suffix}>`
85
+ const vat = g.hasVat ? `\n <P_14_${suffix}>${g.vat.toFixed(2)}</P_14_${suffix}>` : ''
86
+ return net + vat
87
+ }).join('')
88
+
89
+ // TAdres: KodKraju → AdresL1 → [AdresL2] — city goes in AdresL2, no Miejscowosc element
90
+ const adresL2 = seller.sellerCity?.trim()
91
+ ? `\n <AdresL2>${escapeXml(seller.sellerCity)}</AdresL2>`
92
+ : ''
93
+ const adresXml = `
94
+ <Adres>
95
+ <KodKraju>${escapeXml(seller.sellerCountry ?? 'PL')}</KodKraju>
96
+ <AdresL1>${escapeXml(seller.sellerAddressL1)}</AdresL1>${adresL2}
97
+ </Adres>`
98
+
99
+ const grossAmount = parseFloat(String(document.grossAmount)).toFixed(2)
100
+
101
+ // FA(2) v1-0E Fa sequence: KodWaluty → P_1 → P_2A → P_6 → VAT summaries → P_15 → Adnotacje → RodzajFaktury → FaWiersz
102
+ return `<?xml version="1.0" encoding="UTF-8"?>
103
+ <Faktura xmlns="http://crd.gov.pl/wzor/2023/06/29/12648/">
104
+ <Naglowek>
105
+ <KodFormularza kodSystemowy="FA (2)" wersjaSchemy="1-0E">FA</KodFormularza>
106
+ <WariantFormularza>2</WariantFormularza>
107
+ <DataWytworzeniaFa>${now.toISOString()}</DataWytworzeniaFa>
108
+ <SystemInfo>OpenMercato</SystemInfo>
109
+ </Naglowek>
110
+ <Podmiot1>
111
+ <DaneIdentyfikacyjne>
112
+ <NIP>${escapeXml(document.sellerNip)}</NIP>
113
+ <Nazwa>${escapeXml(seller.sellerName)}</Nazwa>
114
+ </DaneIdentyfikacyjne>${adresXml}
115
+ </Podmiot1>
116
+ <Podmiot2>
117
+ <DaneIdentyfikacyjne>
118
+ <NIP>${escapeXml(document.buyerNip)}</NIP>
119
+ <Nazwa>${escapeXml(document.buyerName ?? '')}</Nazwa>
120
+ </DaneIdentyfikacyjne>
121
+ </Podmiot2>
122
+ <Fa>
123
+ <KodWaluty>${escapeXml(document.currency)}</KodWaluty>
124
+ <P_1>${issueDate}</P_1>
125
+ <P_2>${escapeXml(document.invoiceNumber)}</P_2>
126
+ <P_6>${saleDate}</P_6>${vatSummaryXml}
127
+ <P_15>${grossAmount}</P_15>
128
+ <Adnotacje>
129
+ <P_16>2</P_16>
130
+ <P_17>2</P_17>
131
+ <P_18>2</P_18>
132
+ <P_18A>2</P_18A>
133
+ <Zwolnienie>
134
+ <P_19N>1</P_19N>
135
+ </Zwolnienie>
136
+ <NoweSrodkiTransportu>
137
+ <P_22N>1</P_22N>
138
+ </NoweSrodkiTransportu>
139
+ <P_23>2</P_23>
140
+ <PMarzy>
141
+ <P_PMarzyN>1</P_PMarzyN>
142
+ </PMarzy>
143
+ </Adnotacje>
144
+ <RodzajFaktury>VAT</RodzajFaktury>${lineItemsXml}
145
+ </Fa>
146
+ </Faktura>`
147
+ }
@@ -0,0 +1,97 @@
1
+ export interface ParsedReceivedInvoice {
2
+ invoiceNumber: string | null
3
+ sellerNip: string | null
4
+ sellerName: string | null
5
+ issueDate: string | null
6
+ currency: string | null
7
+ netAmount: string | null
8
+ vatAmount: string | null
9
+ grossAmount: string | null
10
+ }
11
+
12
+ function extractSection(xml: string, tag: string): string | null {
13
+ const match = xml.match(new RegExp(`<${tag}[\\s\\S]*?>([\\s\\S]*?)<\\/${tag}>`, 'i'))
14
+ return match ? match[1]! : null
15
+ }
16
+
17
+ function extractTag(xml: string, tag: string): string | null {
18
+ const match = xml.match(new RegExp(`<${tag}[^>]*>([^<]*)<\\/${tag}>`, 'i'))
19
+ return match ? match[1]!.trim() || null : null
20
+ }
21
+
22
+ function sumTagPattern(xml: string, tagPattern: RegExp): number {
23
+ let sum = 0
24
+ for (const match of xml.matchAll(new RegExp(`<(${tagPattern.source})>([^<]*)<\\/\\1>`, 'gi'))) {
25
+ sum += parseFloat(match[2] ?? '0') || 0
26
+ }
27
+ return sum
28
+ }
29
+
30
+ function parseFa2Xml(xml: string): ParsedReceivedInvoice {
31
+ const podmiot1 = extractSection(xml, 'Podmiot1')
32
+ const sellerNip = podmiot1 ? extractTag(podmiot1, 'NIP') : null
33
+ const sellerName = podmiot1
34
+ ? (extractTag(podmiot1, 'PelnaNazwa') ?? extractTag(podmiot1, 'Nazwa'))
35
+ : null
36
+
37
+ const fa = extractSection(xml, 'Fa')
38
+ const issueDate = fa ? extractTag(fa, 'P_1') : null
39
+ const invoiceNumber = fa ? (extractTag(fa, 'P_2A') ?? extractTag(fa, 'P_2')) : null
40
+ const currency = fa ? extractTag(fa, 'KodWaluty') : null
41
+ const grossAmountRaw = fa ? extractTag(fa, 'P_15') : null
42
+
43
+ const netAmountNum = fa ? sumTagPattern(fa, /P_13_\d+/) : 0
44
+ const vatAmountNum = fa ? sumTagPattern(fa, /P_14_\d+/) : 0
45
+
46
+ return {
47
+ invoiceNumber,
48
+ sellerNip,
49
+ sellerName,
50
+ issueDate,
51
+ currency,
52
+ netAmount: netAmountNum > 0 ? netAmountNum.toFixed(2) : null,
53
+ vatAmount: vatAmountNum > 0 ? vatAmountNum.toFixed(2) : null,
54
+ grossAmount: grossAmountRaw,
55
+ }
56
+ }
57
+
58
+ function parseUpoXml(xml: string): ParsedReceivedInvoice {
59
+ // UPO (Potwierdzenie) format from KSeF — xmlns="http://upo.schematy.mf.gov.pl/KSeF/..."
60
+ // Contains metadata about an accepted invoice but not full financial data.
61
+ const dokument = extractSection(xml, 'Dokument')
62
+ const sellerNip = dokument ? extractTag(dokument, 'NipSprzedawcy') : null
63
+ const invoiceNumber = dokument ? extractTag(dokument, 'NumerFaktury') : null
64
+ const issueDate = dokument ? extractTag(dokument, 'DataWystawieniaFaktury') : null
65
+
66
+ return {
67
+ invoiceNumber,
68
+ sellerNip,
69
+ sellerName: null,
70
+ issueDate,
71
+ currency: null,
72
+ netAmount: null,
73
+ vatAmount: null,
74
+ grossAmount: null,
75
+ }
76
+ }
77
+
78
+ export function parseReceivedInvoiceXml(xml: string): ParsedReceivedInvoice {
79
+ try {
80
+ // Detect UPO format by namespace
81
+ if (xml.includes('upo.schematy.mf.gov.pl') || xml.includes('<Potwierdzenie')) {
82
+ return parseUpoXml(xml)
83
+ }
84
+ return parseFa2Xml(xml)
85
+ } catch {
86
+ return {
87
+ invoiceNumber: null,
88
+ sellerNip: null,
89
+ sellerName: null,
90
+ issueDate: null,
91
+ currency: null,
92
+ netAmount: null,
93
+ vatAmount: null,
94
+ grossAmount: null,
95
+ }
96
+ }
97
+ }