@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,183 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import { FileText, Inbox } from 'lucide-react'
6
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
7
+ import { SectionHeader } from '@open-mercato/ui/backend/SectionHeader'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { StatusBadge } from '@open-mercato/ui/primitives/status-badge'
10
+ import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail'
11
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
12
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
13
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
14
+
15
+ export const pageMetadata = {
16
+ requireAuth: true,
17
+ requireFeatures: ['integration_ksef_direct.manage'],
18
+ }
19
+
20
+ type HealthStatus = 'unconfigured' | 'checking' | 'connected' | 'error'
21
+
22
+ type ConnectionState = {
23
+ status: HealthStatus
24
+ lastCheckedAt: string | null
25
+ environment?: 'test' | 'production'
26
+ rateLimits?: { otherPerSecond?: number; otherPerMinute?: number }
27
+ error?: string
28
+ errorCode?: string
29
+ }
30
+
31
+ export default function KsefDirectPage() {
32
+ const t = useT()
33
+ const scopeVersion = useOrganizationScopeVersion()
34
+ const [state, setState] = React.useState<ConnectionState | null>(null)
35
+ const [isLoading, setLoading] = React.useState(false)
36
+ const [isChecking, setChecking] = React.useState(false)
37
+ const [fetchError, setFetchError] = React.useState<string | null>(null)
38
+
39
+ const fetchStatus = React.useCallback(async () => {
40
+ setLoading(true)
41
+ setFetchError(null)
42
+ try {
43
+ const result = await apiCall<ConnectionState>('/api/integration-ksef-direct/health')
44
+ if (result.ok && result.result) {
45
+ setState(result.result)
46
+ } else {
47
+ setFetchError('Failed to load connection status')
48
+ }
49
+ } catch {
50
+ setFetchError('Failed to load connection status')
51
+ } finally {
52
+ setLoading(false)
53
+ }
54
+ }, [])
55
+
56
+ React.useEffect(() => { fetchStatus() }, [fetchStatus, scopeVersion])
57
+
58
+ React.useEffect(() => {
59
+ const handleKeyDown = (e: KeyboardEvent) => {
60
+ if (e.key === 'Escape') {
61
+ window.history.back()
62
+ }
63
+ }
64
+ window.addEventListener('keydown', handleKeyDown)
65
+ return () => window.removeEventListener('keydown', handleKeyDown)
66
+ }, [])
67
+
68
+ async function handleCheckConnection() {
69
+ setChecking(true)
70
+ try {
71
+ const result = await apiCall<ConnectionState>('/api/integration-ksef-direct/health')
72
+ if (result.ok && result.result) {
73
+ setState(result.result)
74
+ }
75
+ } finally {
76
+ setChecking(false)
77
+ }
78
+ }
79
+
80
+ function getStatusVariant(status: HealthStatus): 'success' | 'error' | 'neutral' | 'warning' {
81
+ switch (status) {
82
+ case 'connected': return 'success'
83
+ case 'error': return 'error'
84
+ case 'checking': return 'warning'
85
+ default: return 'neutral'
86
+ }
87
+ }
88
+
89
+ function getStatusLabel(status: HealthStatus): string {
90
+ switch (status) {
91
+ case 'connected': return t('integration_ksef_direct.health.connected', 'Connected')
92
+ case 'error': return t('integration_ksef_direct.health.error', 'Connection error')
93
+ case 'checking': return t('integration_ksef_direct.health.checking', 'Checking...')
94
+ default: return t('integration_ksef_direct.health.unconfigured', 'Not configured')
95
+ }
96
+ }
97
+
98
+ return (
99
+ <Page>
100
+ <PageBody>
101
+ <SectionHeader title={t('integration_ksef_direct.title', 'KSeF Direct Integration')} />
102
+
103
+ {isLoading && !state && <LoadingMessage label={t('integration_ksef_direct.health.loading', 'Loading...')} />}
104
+ {fetchError && !isLoading && <ErrorMessage label={fetchError} />}
105
+
106
+ <div className="grid grid-cols-2 gap-3 mt-2 mb-6">
107
+ <Link href="/backend/integration-ksef-direct/documents">
108
+ <div className="flex items-center gap-3 rounded-lg border border-border p-4 hover:bg-muted/50 transition-colors cursor-pointer">
109
+ <FileText className="h-5 w-5 text-muted-foreground shrink-0" />
110
+ <div>
111
+ <p className="text-sm font-medium">{t('integration_ksef_direct.nav.documents', 'Sent Documents')}</p>
112
+ <p className="text-xs text-muted-foreground">{t('integration_ksef_direct.nav.documents_desc', 'Outgoing invoices sent to KSeF')}</p>
113
+ </div>
114
+ </div>
115
+ </Link>
116
+ <Link href="/backend/integration-ksef-direct/received-documents">
117
+ <div className="flex items-center gap-3 rounded-lg border border-border p-4 hover:bg-muted/50 transition-colors cursor-pointer">
118
+ <Inbox className="h-5 w-5 text-muted-foreground shrink-0" />
119
+ <div>
120
+ <p className="text-sm font-medium">{t('integration_ksef_direct.nav.received_documents', 'Received Documents')}</p>
121
+ <p className="text-xs text-muted-foreground">{t('integration_ksef_direct.nav.received_documents_desc', 'Invoices received from KSeF')}</p>
122
+ </div>
123
+ </div>
124
+ </Link>
125
+ </div>
126
+
127
+ {state && (
128
+ <div className="space-y-4">
129
+ <div className="flex items-center gap-3">
130
+ <StatusBadge variant={getStatusVariant(state.status)}>
131
+ {getStatusLabel(state.status)}
132
+ </StatusBadge>
133
+ {state.environment && (
134
+ <span className="text-sm text-muted-foreground">
135
+ {t('integration_ksef_direct.health.environment', 'Environment:')} <strong>{state.environment.toUpperCase()}</strong>
136
+ </span>
137
+ )}
138
+ </div>
139
+
140
+ {state.lastCheckedAt && (
141
+ <p className="text-sm text-muted-foreground">
142
+ {t('integration_ksef_direct.health.last_checked', 'Last check:')}{' '}
143
+ {new Date(state.lastCheckedAt).toLocaleString()}
144
+ </p>
145
+ )}
146
+
147
+ {state.status === 'connected' && state.rateLimits && (
148
+ <p className="text-sm text-muted-foreground">
149
+ {t('integration_ksef_direct.health.rate_limits', 'Rate limits:')}{' '}
150
+ {[
151
+ state.rateLimits.otherPerSecond != null && `${state.rateLimits.otherPerSecond} req/s`,
152
+ state.rateLimits.otherPerMinute != null && `${state.rateLimits.otherPerMinute} req/min`,
153
+ ]
154
+ .filter(Boolean)
155
+ .join(' · ')}
156
+ </p>
157
+ )}
158
+
159
+ {state.status === 'error' && state.error && (
160
+ <ErrorMessage
161
+ label={state.error}
162
+ description={state.errorCode}
163
+ />
164
+ )}
165
+
166
+ <div className="flex justify-end pt-2">
167
+ <Button
168
+ type="button"
169
+ onClick={handleCheckConnection}
170
+ disabled={isChecking}
171
+ variant="outline"
172
+ >
173
+ {isChecking
174
+ ? t('integration_ksef_direct.health.checking', 'Checking...')
175
+ : t('integration_ksef_direct.health.check_button', 'Check connection')}
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ )}
180
+ </PageBody>
181
+ </Page>
182
+ )
183
+ }
@@ -0,0 +1,93 @@
1
+ import type { CreateKsefDirectDocumentInput, KsefDirectLineItemInput } from '../data/validators'
2
+ import { KsefDirectDocument, type KsefDirectStoredLineItem } from '../data/entities'
3
+ import { emitKsefDirectEvent } from '../events'
4
+
5
+ export class KsefDirectNotConfiguredError extends Error {
6
+ constructor() {
7
+ super('KSEF_DIRECT_NOT_CONFIGURED')
8
+ this.name = 'KsefDirectNotConfiguredError'
9
+ }
10
+ }
11
+
12
+ const VAT_RATE_MAP: Record<string, number> = { '0': 0, '5': 5, '8': 8, '23': 23, ZW: 0, NP: 0 }
13
+
14
+ function round2(value: number): number {
15
+ return Math.round(value * 100) / 100
16
+ }
17
+
18
+ function computeLineItem(item: KsefDirectLineItemInput): KsefDirectStoredLineItem {
19
+ const netAmount = round2(item.quantity * item.unitNetPrice)
20
+ const rate = VAT_RATE_MAP[item.vatRate] ?? 0
21
+ const vatAmount = round2(netAmount * rate / 100)
22
+ return {
23
+ description: item.description,
24
+ quantity: item.quantity,
25
+ unit: item.unit,
26
+ unitNetPrice: item.unitNetPrice,
27
+ vatRate: item.vatRate,
28
+ netAmount,
29
+ vatAmount,
30
+ grossAmount: round2(netAmount + vatAmount),
31
+ }
32
+ }
33
+
34
+ export async function createKsefDirectDocument(
35
+ em: any,
36
+ tenantId: string,
37
+ organizationId: string,
38
+ input: CreateKsefDirectDocumentInput,
39
+ credentialsService: any,
40
+ ): Promise<{ id: string; status: string; invoiceNumber: string; sellerNip: string }> {
41
+ const credentials = await credentialsService?.resolve('integration_ksef_direct', { tenantId, organizationId })
42
+ if (!credentials?.nip) {
43
+ throw new KsefDirectNotConfiguredError()
44
+ }
45
+
46
+ let sellerName = input.sellerName ?? null
47
+ if (!sellerName) {
48
+ const { Organization } = await import('@open-mercato/core/modules/directory/data/entities')
49
+ const org = await em.findOne(Organization, { id: organizationId })
50
+ sellerName = org?.name ?? null
51
+ }
52
+
53
+ const storedLineItems = input.lineItems.map(computeLineItem)
54
+ const netAmount = round2(storedLineItems.reduce((sum, l) => sum + l.netAmount, 0))
55
+ const vatAmount = round2(storedLineItems.reduce((sum, l) => sum + l.vatAmount, 0))
56
+ const grossAmount = round2(netAmount + vatAmount)
57
+ const now = new Date()
58
+
59
+ const doc = em.create(KsefDirectDocument, {
60
+ organizationId,
61
+ tenantId,
62
+ source: 'manual' as const,
63
+ status: 'draft' as const,
64
+ sellerNip: credentials.nip,
65
+ sellerName,
66
+ sellerAddressL1: input.sellerAddressL1 ?? null,
67
+ sellerCity: input.sellerCity ?? null,
68
+ sellerCountry: input.sellerCountry ?? null,
69
+ buyerNip: input.buyerNip,
70
+ buyerName: input.buyerName ?? null,
71
+ invoiceNumber: input.invoiceNumber,
72
+ issueDate: new Date(input.issueDate),
73
+ saleDate: input.saleDate ? new Date(input.saleDate) : null,
74
+ netAmount: String(netAmount),
75
+ vatAmount: String(vatAmount),
76
+ grossAmount: String(grossAmount),
77
+ currency: input.currency ?? 'PLN',
78
+ lineItems: storedLineItems,
79
+ notes: input.notes ?? null,
80
+ createdAt: now,
81
+ updatedAt: now,
82
+ })
83
+
84
+ await em.persist(doc).flush()
85
+
86
+ await emitKsefDirectEvent('ksef_direct.document.created', {
87
+ documentId: doc.id,
88
+ organizationId,
89
+ tenantId,
90
+ })
91
+
92
+ return { id: doc.id, status: doc.status, invoiceNumber: doc.invoiceNumber, sellerNip: doc.sellerNip }
93
+ }
@@ -0,0 +1,57 @@
1
+ import { createModuleQueue } from '@open-mercato/queue'
2
+ import { KsefDirectDocument } from '../data/entities'
3
+ import { emitKsefDirectEvent } from '../events'
4
+
5
+ export class KsefDocumentNotFoundError extends Error {
6
+ readonly status = 404
7
+ constructor() {
8
+ super('Document not found')
9
+ this.name = 'KsefDocumentNotFoundError'
10
+ }
11
+ }
12
+
13
+ export class KsefDocumentNotQueueableError extends Error {
14
+ readonly status = 409
15
+ constructor(currentStatus: string) {
16
+ super(`Document cannot be queued: current status is '${currentStatus}'`)
17
+ this.name = 'KsefDocumentNotQueueableError'
18
+ }
19
+ }
20
+
21
+ export type SendKsefDocumentJobPayload = {
22
+ documentId: string
23
+ organizationId: string
24
+ tenantId: string
25
+ }
26
+
27
+ export async function enqueueKsefDirectDocument(
28
+ em: any,
29
+ tenantId: string,
30
+ organizationId: string,
31
+ documentId: string,
32
+ ): Promise<{ id: string; status: string; invoiceNumber: string }> {
33
+ const doc = await em.findOne(KsefDirectDocument, { id: documentId, organizationId, tenantId })
34
+
35
+ if (!doc) {
36
+ throw new KsefDocumentNotFoundError()
37
+ }
38
+
39
+ if (!['draft', 'failed'].includes(doc.status)) {
40
+ throw new KsefDocumentNotQueueableError(doc.status)
41
+ }
42
+
43
+ doc.status = 'queued'
44
+ doc.updatedAt = new Date()
45
+ await em.flush()
46
+
47
+ await emitKsefDirectEvent('ksef_direct.document.queued', {
48
+ documentId: doc.id,
49
+ organizationId,
50
+ tenantId,
51
+ })
52
+
53
+ const queue = createModuleQueue<SendKsefDocumentJobPayload>('ksef_direct_send')
54
+ await queue.enqueue({ documentId: doc.id, organizationId, tenantId })
55
+
56
+ return { id: doc.id, status: doc.status, invoiceNumber: doc.invoiceNumber }
57
+ }
@@ -0,0 +1,195 @@
1
+ import { Entity, PrimaryKey, Property, Index, Unique } from '@mikro-orm/decorators/legacy'
2
+
3
+ export interface KsefDirectStoredLineItem {
4
+ description: string
5
+ quantity: number
6
+ unit: string
7
+ unitNetPrice: number
8
+ vatRate: string
9
+ netAmount: number
10
+ vatAmount: number
11
+ grossAmount: number
12
+ }
13
+
14
+ @Entity({ tableName: 'ksef_direct_documents' })
15
+ @Index({ properties: ['organizationId', 'tenantId'] })
16
+ @Index({ properties: ['organizationId', 'tenantId', 'status'] })
17
+ @Index({ properties: ['organizationId', 'tenantId', 'source'] })
18
+ @Index({ properties: ['ksefReferenceNumber'] })
19
+ export class KsefDirectDocument {
20
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
21
+ id!: string
22
+
23
+ @Property({ name: 'organization_id', type: 'uuid' })
24
+ organizationId!: string
25
+
26
+ @Property({ name: 'tenant_id', type: 'uuid' })
27
+ tenantId!: string
28
+
29
+ @Property({ name: 'source', type: 'text', default: 'manual' })
30
+ source: 'manual' | 'ksef_sync' | 'sales_invoice' = 'manual'
31
+
32
+ @Property({ name: 'status', type: 'text', default: 'draft' })
33
+ status: 'draft' | 'queued' | 'sending' | 'sent' | 'failed' = 'draft'
34
+
35
+ @Property({ name: 'ksef_reference_number', type: 'text', nullable: true })
36
+ ksefReferenceNumber?: string | null
37
+
38
+ @Property({ name: 'seller_nip', type: 'text' })
39
+ sellerNip!: string
40
+
41
+ @Property({ name: 'buyer_nip', type: 'text' })
42
+ buyerNip!: string
43
+
44
+ @Property({ name: 'buyer_name', type: 'text', nullable: true })
45
+ buyerName?: string | null
46
+
47
+ @Property({ name: 'invoice_number', type: 'text' })
48
+ invoiceNumber!: string
49
+
50
+ @Property({ name: 'issue_date', type: Date })
51
+ issueDate!: Date
52
+
53
+ @Property({ name: 'sale_date', type: Date, nullable: true })
54
+ saleDate?: Date | null
55
+
56
+ @Property({ name: 'net_amount', type: 'string', columnType: 'numeric(15,2)' })
57
+ netAmount!: string
58
+
59
+ @Property({ name: 'vat_amount', type: 'string', columnType: 'numeric(15,2)' })
60
+ vatAmount!: string
61
+
62
+ @Property({ name: 'gross_amount', type: 'string', columnType: 'numeric(15,2)' })
63
+ grossAmount!: string
64
+
65
+ @Property({ name: 'currency', type: 'text', default: 'PLN' })
66
+ currency: string = 'PLN'
67
+
68
+ @Property({ name: 'line_items', type: 'json', columnType: 'jsonb' })
69
+ lineItems: KsefDirectStoredLineItem[] = []
70
+
71
+ @Property({ name: 'notes', type: 'text', nullable: true })
72
+ notes?: string | null
73
+
74
+ @Property({ name: 'ksef_processing_reference_number', type: 'text', nullable: true })
75
+ ksefProcessingReferenceNumber?: string | null
76
+
77
+ @Property({ name: 'seller_name', type: 'text', nullable: true })
78
+ sellerName?: string | null
79
+
80
+ @Property({ name: 'seller_address_l1', type: 'text', nullable: true })
81
+ sellerAddressL1?: string | null
82
+
83
+ @Property({ name: 'seller_city', type: 'text', nullable: true })
84
+ sellerCity?: string | null
85
+
86
+ @Property({ name: 'seller_country', type: 'text', nullable: true })
87
+ sellerCountry?: string | null
88
+
89
+ @Property({ name: 'error_message', type: 'text', nullable: true })
90
+ errorMessage?: string | null
91
+
92
+ @Property({ name: 'created_at', type: Date })
93
+ createdAt: Date = new Date()
94
+
95
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
96
+ updatedAt: Date = new Date()
97
+ }
98
+
99
+ @Entity({ tableName: 'ksef_direct_received_documents' })
100
+ @Index({ properties: ['organizationId', 'tenantId'] })
101
+ @Index({ properties: ['organizationId', 'tenantId', 'status'] })
102
+ @Unique({ properties: ['organizationId', 'ksefReferenceNumber'] })
103
+ export class KsefDirectReceivedDocument {
104
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
105
+ id!: string
106
+
107
+ @Property({ name: 'organization_id', type: 'uuid' })
108
+ organizationId!: string
109
+
110
+ @Property({ name: 'tenant_id', type: 'uuid' })
111
+ tenantId!: string
112
+
113
+ @Property({ name: 'ksef_reference_number', type: 'text' })
114
+ ksefReferenceNumber!: string
115
+
116
+ @Property({ name: 'raw_xml', type: 'text', nullable: true })
117
+ rawXml?: string | null
118
+
119
+ @Property({ name: 'invoice_number', type: 'text', nullable: true })
120
+ invoiceNumber?: string | null
121
+
122
+ @Property({ name: 'seller_nip', type: 'text', nullable: true })
123
+ sellerNip?: string | null
124
+
125
+ @Property({ name: 'seller_name', type: 'text', nullable: true })
126
+ sellerName?: string | null
127
+
128
+ @Property({ name: 'issue_date', type: 'string', columnType: 'date', nullable: true })
129
+ issueDate?: string | null
130
+
131
+ @Property({ name: 'currency', type: 'text', nullable: true })
132
+ currency?: string | null
133
+
134
+ @Property({ name: 'net_amount', type: 'string', columnType: 'numeric(15,2)', nullable: true })
135
+ netAmount?: string | null
136
+
137
+ @Property({ name: 'vat_amount', type: 'string', columnType: 'numeric(15,2)', nullable: true })
138
+ vatAmount?: string | null
139
+
140
+ @Property({ name: 'gross_amount', type: 'string', columnType: 'numeric(15,2)', nullable: true })
141
+ grossAmount?: string | null
142
+
143
+ @Property({ name: 'status', type: 'text', default: 'pending_download' })
144
+ status: 'pending_download' | 'downloaded' | 'failed' = 'pending_download'
145
+
146
+ @Property({ name: 'error_message', type: 'text', nullable: true })
147
+ errorMessage?: string | null
148
+
149
+ @Property({ name: 'upo_download_url', type: 'text', nullable: true })
150
+ upoDownloadUrl?: string | null
151
+
152
+ @Property({ name: 'invoice_download_url', type: 'text', nullable: true })
153
+ invoiceDownloadUrl?: string | null
154
+
155
+ @Property({ name: 'synced_at', type: Date, nullable: true })
156
+ syncedAt?: Date | null
157
+
158
+ @Property({ name: 'created_at', type: Date })
159
+ createdAt: Date = new Date()
160
+
161
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
162
+ updatedAt: Date = new Date()
163
+ }
164
+
165
+ @Entity({ tableName: 'ksef_direct_connections' })
166
+ @Index({ properties: ['status', 'updatedAt'] })
167
+ @Unique({ properties: ['organizationId', 'tenantId'] })
168
+ export class KsefDirectConnection {
169
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
170
+ id!: string
171
+
172
+ @Property({ name: 'organization_id', type: 'uuid' })
173
+ organizationId!: string
174
+
175
+ @Property({ name: 'tenant_id', type: 'uuid' })
176
+ tenantId!: string
177
+
178
+ @Property({ name: 'status', type: 'text', default: 'unconfigured' })
179
+ status: 'unconfigured' | 'checking' | 'connected' | 'error' = 'unconfigured'
180
+
181
+ @Property({ name: 'last_checked_at', type: Date, nullable: true })
182
+ lastCheckedAt?: Date | null
183
+
184
+ @Property({ name: 'error_message', type: 'text', nullable: true })
185
+ errorMessage?: string | null
186
+
187
+ @Property({ name: 'error_code', type: 'text', nullable: true })
188
+ errorCode?: string | null
189
+
190
+ @Property({ name: 'created_at', type: Date })
191
+ createdAt: Date = new Date()
192
+
193
+ @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() })
194
+ updatedAt: Date = new Date()
195
+ }
@@ -0,0 +1,115 @@
1
+ import { z } from 'zod'
2
+
3
+ const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD')
4
+ const vatRates = ['0', '5', '8', '23', 'ZW', 'NP'] as const
5
+
6
+ export const KsefDirectLineItemSchema = z.object({
7
+ description: z.string().min(1),
8
+ quantity: z.number().positive(),
9
+ unit: z.string().min(1).default('szt'),
10
+ unitNetPrice: z.number().min(0),
11
+ vatRate: z.enum(vatRates),
12
+ })
13
+
14
+ export const CreateKsefDirectDocumentSchema = z.object({
15
+ buyerNip: z.string().regex(/^\d{10}$/, 'NIP must be 10 digits'),
16
+ buyerName: z.string().max(256).optional(),
17
+ invoiceNumber: z.string().min(1).max(256),
18
+ issueDate: dateString,
19
+ saleDate: dateString.optional(),
20
+ currency: z.string().length(3).default('PLN'),
21
+ lineItems: z.array(KsefDirectLineItemSchema).min(1),
22
+ notes: z.string().optional(),
23
+ sellerName: z.string().min(1).max(512).optional(),
24
+ sellerAddressL1: z.string().min(1).max(512),
25
+ sellerCity: z.string().min(1).max(256),
26
+ sellerCountry: z.string().length(2).optional(),
27
+ })
28
+
29
+ export type CreateKsefDirectDocumentInput = z.infer<typeof CreateKsefDirectDocumentSchema>
30
+ export type KsefDirectLineItemInput = z.infer<typeof KsefDirectLineItemSchema>
31
+
32
+ export const KsefDirectConnectionStatusSchema = z.enum(['unconfigured', 'checking', 'connected', 'error'])
33
+
34
+ export const KsefDirectCredentialsSchema = z.object({
35
+ ksef_token: z.string().min(1),
36
+ nip: z.string().min(1),
37
+ environment: z.enum(['test', 'production']),
38
+ })
39
+
40
+ export const KsefDirectRateLimitsSchema = z.object({
41
+ otherPerSecond: z.number().optional(),
42
+ otherPerMinute: z.number().optional(),
43
+ })
44
+
45
+ export const KsefDirectHealthResponseSchema = z.discriminatedUnion('status', [
46
+ z.object({
47
+ status: z.literal('connected'),
48
+ lastCheckedAt: z.string(),
49
+ environment: z.enum(['test', 'production']),
50
+ rateLimits: KsefDirectRateLimitsSchema.optional(),
51
+ }),
52
+ z.object({
53
+ status: z.literal('error'),
54
+ lastCheckedAt: z.string(),
55
+ error: z.string(),
56
+ errorCode: z.string(),
57
+ }),
58
+ z.object({
59
+ status: z.literal('unconfigured'),
60
+ lastCheckedAt: z.null(),
61
+ }),
62
+ z.object({
63
+ status: z.literal('checking'),
64
+ lastCheckedAt: z.string().nullable(),
65
+ }),
66
+ ])
67
+
68
+ export type KsefDirectCredentials = z.infer<typeof KsefDirectCredentialsSchema>
69
+ export type KsefDirectHealthResponse = z.infer<typeof KsefDirectHealthResponseSchema>
70
+ export type KsefDirectRateLimits = z.infer<typeof KsefDirectRateLimitsSchema>
71
+
72
+ export const KsefSendInvoiceResponseSchema = z.object({
73
+ referenceNumber: z.string(),
74
+ processingCode: z.number().optional(),
75
+ processingDescription: z.string().optional(),
76
+ timestamp: z.string().optional(),
77
+ }).passthrough()
78
+
79
+ export const KsefInvoiceStatusResponseSchema = z.object({
80
+ referenceNumber: z.string(),
81
+ processingCode: z.number(),
82
+ processingDescription: z.string().optional(),
83
+ invoiceStatus: z.object({
84
+ ksefReferenceNumber: z.string().optional(),
85
+ invoiceNumber: z.string().optional(),
86
+ }).optional(),
87
+ }).passthrough()
88
+
89
+ export type KsefSendInvoiceResponse = z.infer<typeof KsefSendInvoiceResponseSchema>
90
+ export type KsefInvoiceStatusResponse = z.infer<typeof KsefInvoiceStatusResponseSchema>
91
+
92
+ const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD')
93
+
94
+ export const ReceivedDocumentSyncSchema = z.object({
95
+ dateFrom: isoDate,
96
+ dateTo: isoDate,
97
+ }).refine(
98
+ (data) => data.dateTo >= data.dateFrom,
99
+ { message: 'dateTo must be >= dateFrom', path: ['dateTo'] },
100
+ ).refine(
101
+ (data) => {
102
+ const from = new Date(data.dateFrom)
103
+ const to = new Date(data.dateTo)
104
+ const diffDays = (to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24)
105
+ return diffDays <= 366
106
+ },
107
+ { message: 'Date range must not exceed 366 days', path: ['dateTo'] },
108
+ )
109
+
110
+ export const ReceivedDocumentFetchSchema = z.object({
111
+ ksefReferenceNumber: z.string().min(1),
112
+ })
113
+
114
+ export type ReceivedDocumentSyncInput = z.infer<typeof ReceivedDocumentSyncSchema>
115
+ export type ReceivedDocumentFetchInput = z.infer<typeof ReceivedDocumentFetchSchema>
@@ -0,0 +1,9 @@
1
+ import { asValue } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { ksefDirectHealthChecker } from './lib/health'
4
+
5
+ export function register(container: AppContainer) {
6
+ container.register({
7
+ ksefDirectHealthChecker: asValue(ksefDirectHealthChecker),
8
+ })
9
+ }
@@ -0,0 +1,18 @@
1
+ import { createModuleEvents } from '@open-mercato/shared/modules/events'
2
+
3
+ const events = [
4
+ { id: 'ksef_direct.connection.connected', label: 'KSeF Direct Connection Established', entity: 'ksef_direct_connection', category: 'lifecycle' },
5
+ { id: 'ksef_direct.connection.failed', label: 'KSeF Direct Connection Failed', entity: 'ksef_direct_connection', category: 'lifecycle' },
6
+ { id: 'ksef_direct.connection.checked', label: 'KSeF Direct Connection Checked', entity: 'ksef_direct_connection', category: 'lifecycle' },
7
+ { id: 'ksef_direct.document.created', label: 'KSeF Direct Document Created', entity: 'ksef_direct_document', category: 'lifecycle' },
8
+ { id: 'ksef_direct.document.queued', label: 'KSeF Direct Document Queued', entity: 'ksef_direct_document', category: 'lifecycle', clientBroadcast: true },
9
+ { id: 'ksef_direct.document.sent', label: 'KSeF Direct Document Sent', entity: 'ksef_direct_document', category: 'lifecycle', clientBroadcast: true },
10
+ { id: 'ksef_direct.document.failed', label: 'KSeF Direct Document Failed', entity: 'ksef_direct_document', category: 'lifecycle', clientBroadcast: true },
11
+ { id: 'ksef_direct.received_document.synced', label: 'KSeF Direct Received Documents Synced', entity: 'ksef_direct_received_document', category: 'lifecycle' },
12
+ { id: 'ksef_direct.received_document.failed', label: 'KSeF Direct Received Document Failed', entity: 'ksef_direct_received_document', category: 'lifecycle' },
13
+ ] as const
14
+
15
+ export const eventsConfig = createModuleEvents({ moduleId: 'integration_ksef_direct', events })
16
+ export const emitKsefDirectEvent = eventsConfig.emit
17
+ export type KsefDirectEventId = typeof events[number]['id']
18
+ export default eventsConfig