diff --git a/bin/lms-ksef.php b/bin/lms-ksef.php new file mode 100755 index 0000000000..c35484be04 --- /dev/null +++ b/bin/lms-ksef.php @@ -0,0 +1,121 @@ +#!/usr/bin/env php + null, + 'sync' => null, + 'test' => 't', + 'section:' => 's:', + 'division:' => null, + 'customerid:' => null, +]; + +$script_help = << configuration section name, default: ksef; + --division= limit sending candidates to selected division; + --customerid= limit sending candidates to selected customer; +EOF; + +require_once('script-options.php'); + +$send = isset($options['send']); +$sync = isset($options['sync']); + +if (!$send && !$sync) { + die('Use --send and/or --sync.' . PHP_EOL); +} + +$SYSLOG = SYSLOG::getInstance(); +$AUTH = null; +$LMS = new LMS($DB, $AUTH, $SYSLOG); + +$plugin_manager = LMSPluginManager::getInstance(); +$LMS->setPluginManager($plugin_manager); + +$section = isset($options['section']) && preg_match('/^[a-z0-9-_]+$/i', $options['section']) + ? $options['section'] + : 'ksef'; +$repository = new KSeFRepository($DB); + +$divisionId = null; +if (!empty($options['division'])) { + $divisionId = $LMS->getDivisionIdByShortName($options['division']); + if (empty($divisionId)) { + die('Unknown division: ' . $options['division'] . PHP_EOL); + } + ConfigHelper::setFilter($divisionId); +} +$customerId = isset($options['customerid']) ? intval($options['customerid']) : null; +$configProvider = function (?int $selectedDivisionId = null) use ($section, $options) { + if ($selectedDivisionId !== null) { + ConfigHelper::setFilter($selectedDivisionId); + } + + return KSeFConfig::fromConfigHelper($section, !isset($options['test'])); +}; +$config = KSeFConfig::fromConfigHelper($section, false); + +if (isset($options['test'])) { + if ($send) { + $eligible = $repository->getEligibleInvoices($config->getMaxDocuments(), $divisionId, $customerId); + echo 'KSeF send candidates: ' . count($eligible) . PHP_EOL; + } + if ($sync) { + $pending = $repository->getPendingDocuments($config->getMaxDocuments(), $divisionId, $customerId); + echo 'KSeF pending documents: ' . count($pending) . PHP_EOL; + } + exit(0); +} + +$gateway = new N1ebieskiKSeFGateway(); +$ksef = new KSeF($DB, $LMS); +$service = new KSeFSubmissionService( + $repository, + $gateway, + function (array $invoice) use ($LMS, $ksef) { + $invoiceContent = $LMS->GetInvoiceContent((int) $invoice['id']); + if (empty($invoiceContent)) { + return ['error' => 'Invoice not found.']; + } + + return $ksef->getInvoiceXml($invoiceContent); + }, + $configProvider +); + +if ($send) { + $result = $service->send($config, $divisionId, $customerId); + echo 'KSeF submitted: ' . $result['submitted'] . ', skipped: ' . $result['skipped'] . PHP_EOL; + foreach ($result['errors'] as $error) { + echo 'Document ' . $error['docid'] . ': ' . $error['error'] . PHP_EOL; + } +} + +if ($sync) { + $result = $service->sync($config, $divisionId, $customerId); + echo 'KSeF status updates: ' . $result['updated'] . PHP_EOL; + foreach ($result['errors'] as $error) { + echo 'KSeF document ' . $error['id'] . ': ' . $error['error'] . PHP_EOL; + } +} diff --git a/js/locale/pl_PL.js b/js/locale/pl_PL.js index 66766949c8..56898ff073 100644 --- a/js/locale/pl_PL.js +++ b/js/locale/pl_PL.js @@ -6361,6 +6361,10 @@ $_LANG['corrective PEF invoice'] = 'korekta faktury PEF'; $_LANG['RR invoice'] = 'faktura RR'; $_LANG['corrective RR invoice'] = 'korekta faktury RR'; +$_LANG['Send invoice $a to KSeF?'] = 'Wysłać fakturę $a do KSeF?'; +$_LANG['Send selected invoices to KSeF?'] = 'Wysłać zaznaczone faktury do KSeF?'; +$_LANG['Sending invoices to KSeF. Please wait.'] = 'Wysyłanie faktur do KSeF. Proszę czekać.'; + $_LANG['selection from filter'] = 'wybór z filtra'; $_LANG['all recipient of this message'] = 'wszyscy odbiorcy tej wiadomości'; $_LANG['recipients who haven\'t received this message'] = 'odbiorcy, którzy nie otrzymali tej wiadomości'; diff --git a/lib/KSeF/KSeF.php b/lib/KSeF/KSeF.php index f5c72c38db..7930a3d00d 100644 --- a/lib/KSeF/KSeF.php +++ b/lib/KSeF/KSeF.php @@ -127,6 +127,35 @@ class KSeF private $smartNumberFormatter; + public static function formatStatusDetails($statusDetails) + { + if (!is_string($statusDetails) || $statusDetails === '') { + return $statusDetails; + } + + $decoded = json_decode($statusDetails, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return $statusDetails; + } + + if (is_array($decoded)) { + return implode(', ', array_map([self::class, 'formatStatusDetailValue'], $decoded)); + } + + return is_scalar($decoded) ? strval($decoded) : $statusDetails; + } + + private static function formatStatusDetailValue($value) + { + if (is_scalar($value) || $value === null) { + return strval($value); + } + + $encoded = json_encode($value, JSON_UNESCAPED_UNICODE); + + return $encoded === false ? '' : $encoded; + } + public function __construct($db, $lms) { $this->db = $db; @@ -384,6 +413,8 @@ private function smartFormatNumber($number) public function getInvoiceXml(array $invoice) { + $invoiceType = $invoice['type'] ?? $invoice['doctype'] ?? null; + if (!isset($this->divisions[$invoice['divisionid']])) { $this->divisions[$invoice['divisionid']] = $this->lms->GetDivision($invoice['divisionid']); } @@ -604,7 +635,7 @@ public function getInvoiceXml(array $invoice) $taxFree = false; $diffTotal = 0; - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest']['23.00']) || isset($invoice['invoice']['taxest']['23.00'])) { $taxRate = '23.00'; } elseif (isset($invoice['taxest']['22.00']) || isset($invoice['invoice']['taxest']['22.00'])) { @@ -650,7 +681,7 @@ public function getInvoiceXml(array $invoice) } } - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest']['8.00']) || isset($invoice['invoice']['taxest']['8.00'])) { $taxRate = '8.00'; } elseif (isset($invoice['taxest']['7.00']) || isset($invoice['invoice']['taxest']['7.00'])) { @@ -696,7 +727,7 @@ public function getInvoiceXml(array $invoice) } } - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest']['5.00']) || isset($invoice['invoice']['taxest']['5.00'])) { $taxRate = '5.00'; } else { @@ -739,7 +770,7 @@ public function getInvoiceXml(array $invoice) } if (!$foreign) { - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest']['0.00']) || isset($invoice['invoice']['taxest']['0.00'])) { $taxRate = '0.00'; if (isset($invoice['taxest'][$taxRate])) { @@ -759,7 +790,7 @@ public function getInvoiceXml(array $invoice) } } - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest']['-1']) || isset($invoice['invoice']['taxest']['-1'])) { $taxRate = '-1'; if (isset($invoice['taxest'][$taxRate])) { @@ -793,7 +824,7 @@ public function getInvoiceXml(array $invoice) } } - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest']['-2']) || isset($invoice['invoice']['taxest']['-2'])) { $taxRate = '-2'; if (isset($invoice['taxest'][$taxRate])) { @@ -814,7 +845,7 @@ public function getInvoiceXml(array $invoice) } } - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { $xml .= "\t\t" . sprintf('%.2f', $diffTotal) . "" . PHP_EOL; } else { $xml .= "\t\t" . sprintf('%.2f', $invoice['total']) . "" . PHP_EOL; @@ -852,7 +883,7 @@ public function getInvoiceXml(array $invoice) $xml .= "\t\t\t" . PHP_EOL; $xml .= "\t\t" . PHP_EOL; - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { $xml .= "\t\tKOR" . PHP_EOL; if (!empty($invoice['reason'])) { $xml .= "\t\t" . htmlspecialchars($invoice['reason']) . "" . PHP_EOL; @@ -922,7 +953,7 @@ public function getInvoiceXml(array $invoice) foreach ($invoice['content'] as $position) { $itemId = $position['itemid']; - if ($invoice['type'] == DOC_CNOTE && !empty($refInvoiceContent[$itemId])) { + if ($invoiceType == DOC_CNOTE && !empty($refInvoiceContent[$itemId])) { $description = htmlspecialchars($refInvoiceContent[$itemId]['description']); if (mb_strlen($description) > 512) { $description = mb_substr($description, 0, 512 - strlen(' [...]')) . ' [...]'; @@ -1062,7 +1093,7 @@ public function getInvoiceXml(array $invoice) } if (!empty($invoice['ksefshowbalancesummary'])) { - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { $total = $diffTotal; } else { $total = $invoice['total']; @@ -1624,11 +1655,7 @@ public static function getCertificateQrCodeUrl(array $params): string public static function downloadUpoFile($invoiceStatus) { - if (!isset(self::$upoStorage)) { - self::$upoStorage = is_dir(self::KSEF_UPO_DIR) && is_readable(self::KSEF_UPO_DIR); - } - - if (!self::$upoStorage) { + if (!self::ensureUpoStorageDirectory()) { return false; } @@ -1642,7 +1669,20 @@ public static function downloadUpoFile($invoiceStatus) return 'Couldn\'t download UPO file for KSeF invoice \'' . $invoiceStatus->ksefNumber . '\'!'; } - [$ten, $date] = explode('-', $invoiceStatus->ksefNumber); + return self::saveUpoContent($invoiceStatus->ksefNumber, $upoContent); + } + + public static function saveUpoContent($ksefNumber, $upoContent) + { + if (!self::ensureUpoStorageDirectory()) { + return false; + } + + if (!is_string($upoContent) || $upoContent === '') { + return 'Empty UPO file content for KSeF invoice \'' . $ksefNumber . '\'!'; + } + + [$ten, $date] = explode('-', $ksefNumber); $ksefUpoTenDir = self::KSEF_UPO_DIR . DIRECTORY_SEPARATOR . $ten; if (!is_dir($ksefUpoTenDir)) { @@ -1666,7 +1706,7 @@ public static function downloadUpoFile($invoiceStatus) @chgrp($ksefUpoTenDateDir, filegroup(self::KSEF_UPO_DIR)); } - $upoFile = $ksefUpoTenDateDir . DIRECTORY_SEPARATOR . $invoiceStatus->ksefNumber . '.xml'; + $upoFile = $ksefUpoTenDateDir . DIRECTORY_SEPARATOR . $ksefNumber . '.xml'; if (file_put_contents($upoFile, $upoContent) !== false) { @chmod( $upoFile, @@ -1675,12 +1715,36 @@ public static function downloadUpoFile($invoiceStatus) @chown($upoFile, fileowner(self::KSEF_UPO_DIR)); @chgrp($upoFile, filegroup(self::KSEF_UPO_DIR)); } else { - return 'Couldn\'t write UPO file for KSeF invoice \'' . $invoiceStatus->ksefNumber . '\'!'; + return 'Couldn\'t write UPO file for KSeF invoice \'' . $ksefNumber . '\'!'; } return true; } + private static function ensureUpoStorageDirectory() + { + if (!is_dir(self::KSEF_UPO_DIR)) { + $permissions = is_dir(STORAGE_DIR) ? fileperms(STORAGE_DIR) & 0xfff : 0775; + @mkdir(self::KSEF_UPO_DIR, $permissions, true); + + if (is_dir(STORAGE_DIR)) { + $ksefDir = dirname(self::KSEF_UPO_DIR); + @chmod($ksefDir, $permissions); + @chmod(self::KSEF_UPO_DIR, $permissions); + @chown($ksefDir, fileowner(STORAGE_DIR)); + @chown(self::KSEF_UPO_DIR, fileowner(STORAGE_DIR)); + @chgrp($ksefDir, filegroup(STORAGE_DIR)); + @chgrp(self::KSEF_UPO_DIR, filegroup(STORAGE_DIR)); + } + } + + self::$upoStorage = is_dir(self::KSEF_UPO_DIR) + && is_readable(self::KSEF_UPO_DIR) + && is_writable(self::KSEF_UPO_DIR); + + return self::$upoStorage; + } + private static function getUpoFilePath($ksefNumber) { if (!isset(self::$upoStorage)) { diff --git a/lib/KSeF/KSeFConfig.php b/lib/KSeF/KSeFConfig.php new file mode 100644 index 0000000000..64df7874b4 --- /dev/null +++ b/lib/KSeF/KSeFConfig.php @@ -0,0 +1,178 @@ +environment = $environment; + $this->environmentName = $environmentName; + $this->authMethod = $authMethod; + $this->token = $token; + $this->certificatePath = $certificatePath; + $this->certificatePassword = $certificatePassword; + $this->maxDocuments = $maxDocuments; + $this->invoiceReferencePageSize = $invoiceReferencePageSize; + } + + public static function fromArray(array $config, bool $validateCredentials = true): self + { + [$environment, $environmentName] = self::parseEnvironment($config['environment'] ?? 'test'); + $token = self::nullableString($config['token'] ?? null); + $certificatePath = self::nullableString($config['certificate_path'] ?? null); + $certificatePassword = self::nullableString($config['certificate_password'] ?? null); + $authMethod = strtolower(trim( + $config['auth_method'] ?? ($token === null ? self::AUTH_METHOD_CERTIFICATE : self::AUTH_METHOD_TOKEN) + )); + $maxDocuments = min(10000, max(1, (int) ($config['max_documents'] ?? 10000))); + $invoiceReferencePageSize = min( + 1000, + max(10, (int) ($config['invoice_reference_page_size'] ?? 1000)) + ); + + if (!in_array($authMethod, [self::AUTH_METHOD_TOKEN, self::AUTH_METHOD_CERTIFICATE], true)) { + throw new \InvalidArgumentException('Unsupported KSeF auth method: ' . $authMethod); + } + + if ($validateCredentials && $authMethod === self::AUTH_METHOD_TOKEN && $token === null) { + throw new \InvalidArgumentException('KSeF token is required for token authentication.'); + } + + if ($validateCredentials && $authMethod === self::AUTH_METHOD_CERTIFICATE && $certificatePath === null) { + throw new \InvalidArgumentException('KSeF certificate path is required for certificate authentication.'); + } + + return new self( + $environment, + $environmentName, + $authMethod, + $token, + $certificatePath, + $certificatePassword, + $maxDocuments, + $invoiceReferencePageSize + ); + } + + public static function fromConfigHelper(string $section = 'ksef', bool $validateCredentials = true): self + { + $token = \ConfigHelper::getConfig($section . '.token'); + + return self::fromArray([ + 'environment' => \ConfigHelper::getConfig($section . '.environment', 'test'), + 'auth_method' => \ConfigHelper::getConfig( + $section . '.auth_method', + self::nullableString($token) === null ? self::AUTH_METHOD_CERTIFICATE : self::AUTH_METHOD_TOKEN + ), + 'token' => $token, + 'certificate_path' => self::resolveCertificatePath(\ConfigHelper::getConfig($section . '.certificate')), + 'certificate_password' => \ConfigHelper::getConfig($section . '.password'), + 'max_documents' => \ConfigHelper::getConfig($section . '.max_documents', 10000), + 'invoice_reference_page_size' => \ConfigHelper::getConfig($section . '.invoice_reference_page_size', 1000), + ], $validateCredentials); + } + + public function getEnvironment(): int + { + return $this->environment; + } + + public function getEnvironmentName(): string + { + return $this->environmentName; + } + + public function getAuthMethod(): string + { + return $this->authMethod; + } + + public function getToken(): ?string + { + return $this->token; + } + + public function getCertificatePath(): ?string + { + return $this->certificatePath; + } + + public function getCertificatePassword(): ?string + { + return $this->certificatePassword; + } + + public function getMaxDocuments(): int + { + return $this->maxDocuments; + } + + public function getInvoiceReferencePageSize(): int + { + return $this->invoiceReferencePageSize; + } + + private static function parseEnvironment($environment): array + { + $environment = strtolower(trim((string) $environment)); + + switch ($environment) { + case 'test': + case '1': + return [KSeF::ENVIRONMENT_TEST, 'test']; + case 'prod': + case 'production': + case '2': + return [KSeF::ENVIRONMENT_PROD, 'production']; + case 'demo': + case '3': + return [KSeF::ENVIRONMENT_DEMO, 'demo']; + default: + throw new \InvalidArgumentException('Unsupported KSeF environment: ' . $environment); + } + } + + private static function nullableString($value): ?string + { + if ($value === null) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; + } + + private static function resolveCertificatePath($certificatePath): ?string + { + $certificatePath = self::nullableString($certificatePath); + if ($certificatePath === null) { + return null; + } + + return strpos($certificatePath, DIRECTORY_SEPARATOR) === 0 + ? $certificatePath + : SYS_DIR . DIRECTORY_SEPARATOR . $certificatePath; + } +} diff --git a/lib/KSeF/KSeFGatewayInterface.php b/lib/KSeF/KSeFGatewayInterface.php new file mode 100644 index 0000000000..79d9d24c9a --- /dev/null +++ b/lib/KSeF/KSeFGatewayInterface.php @@ -0,0 +1,21 @@ +db = $db; + } + + public function getEligibleInvoices( + int $limit, + ?int $divisionId = null, + ?int $customerId = null, + ?array $docIds = null + ): array { + $conditions = [ + 'd.cancelled = 0', + 'd.type IN (' . implode(',', [DOC_INVOICE, DOC_CNOTE]) . ')', + 'd.cdate >= kc.boundarydate', + 'kc.delay > -1', + '?NOW? - d.cdate >= kc.delay', + '(c.type = ' . CTYPES_COMPANY + . ' OR kc.allconsumers = 1' + . ' OR EXISTS (SELECT 1 FROM customerconsents cc WHERE cc.customerid = d.customerid AND cc.type = ' + . CCONSENT_KSEF_INVOICE . '))', + 'NOT EXISTS ( + SELECT 1 FROM ksefdocuments kd + WHERE kd.docid = d.id + AND (kd.status = 0 OR kd.status = 200) + )', + ]; + + if ($divisionId !== null) { + $conditions[] = 'd.divisionid = ' . intval($divisionId); + } + if ($customerId !== null) { + $conditions[] = 'd.customerid = ' . intval($customerId); + } + $docIds = $this->normalizeIds($docIds); + if (!empty($docIds)) { + $conditions[] = 'd.id IN (' . implode(',', $docIds) . ')'; + } + + $query = 'SELECT + d.id, + d.divisionid, + d.div_ten AS division_ten + FROM documents d + JOIN customers c ON c.id = d.customerid + JOIN ksefconfig kc ON kc.divisionid = d.divisionid + WHERE ' . implode(' AND ', $conditions) . ' + ORDER BY d.cdate, d.id + LIMIT ' . intval($limit); + + return $this->db->GetAll($query) ?: []; + } + + public function reserveInvoices(array $documents, int $environment, int $createdAt): array + { + if (empty($documents)) { + throw new \InvalidArgumentException('KSeF invoice reservation requires at least one document.'); + } + + $sessionReferenceNumber = $this->localReference('LOCAL-S', (int) $documents[0]['docid']); + + $this->db->BeginTrans(); + try { + $reservableDocuments = []; + $skippedDocuments = []; + + foreach ($documents as $document) { + $docId = (int) $document['docid']; + $lockedDocId = $this->db->GetOne( + 'SELECT id FROM documents WHERE id = ? FOR UPDATE', + [ + $docId, + ] + ); + if (empty($lockedDocId)) { + $skippedDocuments[$docId] = 'Invoice not found.'; + continue; + } + + $alreadyPendingOrAccepted = $this->db->GetOne( + 'SELECT 1 FROM ksefdocuments + WHERE docid = ? + AND (status = ? OR status = ?)', + [ + $docId, + KSeFSubmissionService::STATUS_PENDING, + KSeFSubmissionService::STATUS_ACCEPTED, + ] + ); + if (!empty($alreadyPendingOrAccepted)) { + $skippedDocuments[$docId] = 'Invoice is already reserved for KSeF submission.'; + continue; + } + + $reservableDocuments[] = [ + 'docid' => $docId, + 'hash' => $document['hash'], + ]; + } + + if (empty($reservableDocuments)) { + $this->db->RollbackTrans(); + return [ + 'skipped' => $skippedDocuments, + 'documents' => [], + ]; + } + + $this->db->Execute( + 'INSERT INTO ksefbatchsessions (ksefnumber, cdate, lastupdate, status, statusdescription, environment) + VALUES (?, ?, ?, ?, ?, ?)', + [ + $sessionReferenceNumber, + $createdAt, + $createdAt, + KSeFSubmissionService::STATUS_PENDING, + 'Reserved for KSeF submission.', + $environment, + ] + ); + $sessionId = (int) $this->db->GetLastInsertID('ksefbatchsessions'); + + $reservedDocuments = []; + foreach ($reservableDocuments as $index => $document) { + $ordinalNumber = $index + 1; + $this->db->Execute( + 'INSERT INTO ksefdocuments + (batchsessionid, docid, ordinalnumber, hash, status, statusdescription, statusdetails) + VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + $sessionId, + $document['docid'], + $ordinalNumber, + $document['hash'], + KSeFSubmissionService::STATUS_PENDING, + 'Reserved for KSeF submission.', + null, + ] + ); + $reservedDocuments[] = [ + 'docid' => $document['docid'], + 'document_id' => (int) $this->db->GetLastInsertID('ksefdocuments'), + 'ordinalnumber' => $ordinalNumber, + ]; + } + $this->db->CommitTrans(); + + return [ + 'session_id' => $sessionId, + 'session_reference_number' => $sessionReferenceNumber, + 'documents' => $reservedDocuments, + 'skipped' => $skippedDocuments, + ]; + } catch (\Throwable $e) { + $this->db->RollbackTrans(); + throw $e; + } + } + + public function updateSessionReference(int $id, string $referenceNumber): void + { + $this->db->Execute( + 'UPDATE ksefbatchsessions + SET ksefnumber = ?, + lastupdate = ?NOW?, + statusdescription = ? + WHERE id = ?', + [ + $referenceNumber, + 'KSeF session opened.', + $id, + ] + ); + } + + public function closeSession(int $id): void + { + $this->db->Execute( + 'UPDATE ksefbatchsessions + SET status = ?, + lastupdate = ?NOW?, + statusdescription = ? + WHERE id = ?', + [ + KSeFSubmissionService::STATUS_ACCEPTED, + 'KSeF session closed.', + $id, + ] + ); + } + + public function discardSession(int $id): void + { + $this->db->BeginTrans(); + try { + $this->db->Execute( + 'DELETE FROM ksefdocuments + WHERE batchsessionid = ?', + [ + $id, + ] + ); + $this->db->Execute( + 'DELETE FROM ksefbatchsessions + WHERE id = ?', + [ + $id, + ] + ); + $this->db->CommitTrans(); + } catch (\Throwable $e) { + $this->db->RollbackTrans(); + throw $e; + } + } + + public function getPendingDocuments( + int $limit, + ?int $divisionId = null, + ?int $customerId = null, + ?array $docIds = null + ): array { + $conditions = [ + 'kd.status = ?', + 'kbs.ksefnumber NOT LIKE ?', + ]; + $params = [ + KSeFSubmissionService::STATUS_PENDING, + 'LOCAL-S-%', + ]; + + if ($divisionId !== null) { + $conditions[] = 'd.divisionid = ?'; + $params[] = $divisionId; + } + if ($customerId !== null) { + $conditions[] = 'd.customerid = ?'; + $params[] = $customerId; + } + $docIds = $this->normalizeIds($docIds); + if (!empty($docIds)) { + $conditions[] = 'd.id IN (' . implode(',', $docIds) . ')'; + } + + return $this->db->GetAll( + 'SELECT + kd.id, + d.id AS docid, + kd.batchsessionid, + kd.ordinalnumber, + session_documents.document_count AS session_document_count, + kbs.ksefnumber AS session_reference_number, + kbs.status AS session_status, + d.divisionid, + d.div_ten AS seller_ten + FROM ksefdocuments kd + JOIN ksefbatchsessions kbs ON kbs.id = kd.batchsessionid + JOIN documents d ON d.id = kd.docid + JOIN ( + SELECT batchsessionid, COUNT(*) AS document_count + FROM ksefdocuments + GROUP BY batchsessionid + ) session_documents ON session_documents.batchsessionid = kd.batchsessionid + WHERE ' . implode(' AND ', $conditions) . ' + ORDER BY kbs.lastupdate, kd.id + LIMIT ' . intval($limit), + $params + ) ?: []; + } + + public function updateDocumentStatus( + int $id, + int $status, + ?string $statusDescription, + ?string $statusDetails, + ?string $ksefNumber, + ?string $permanentStorageDate + ): void { + $this->db->Execute( + 'UPDATE ksefdocuments + SET status = ?, + statusdescription = ?, + statusdetails = ?, + ksefnumber = ?, + permanent_storage_date = ? + WHERE id = ?', + [ + $status, + $statusDescription, + $statusDetails, + $ksefNumber, + $permanentStorageDate, + $id, + ] + ); + } + + public function saveUpo(string $ksefNumber, string $content): void + { + $result = KSeF::saveUpoContent($ksefNumber, $content); + if ($result !== true) { + throw new \RuntimeException(is_string($result) ? $result : 'Couldn\'t save KSeF UPO file.'); + } + } + + private function localReference(string $prefix, int $docId): string + { + return $prefix . '-' . $docId . '-' . substr(hash('sha1', uniqid('', true)), 0, 12); + } + + private function normalizeIds(?array $ids): array + { + if (empty($ids)) { + return []; + } + + return array_values(array_unique(array_filter(array_map('intval', $ids)))); + } +} diff --git a/lib/KSeF/KSeFRepositoryInterface.php b/lib/KSeF/KSeFRepositoryInterface.php new file mode 100644 index 0000000000..e9ff67b3bb --- /dev/null +++ b/lib/KSeF/KSeFRepositoryInterface.php @@ -0,0 +1,39 @@ +repository = $repository; + $this->gateway = $gateway; + $this->xmlBuilder = $xmlBuilder; + $this->configProvider = $configProvider; + $this->sleeper = $sleeper ?: 'sleep'; + } + + public function send( + KSeFConfig $config, + ?int $divisionId = null, + ?int $customerId = null, + ?array $docIds = null + ): array { + $result = [ + 'submitted' => 0, + 'skipped' => 0, + 'errors' => [], + ]; + + $invoices = $this->repository->getEligibleInvoices( + $this->getDocumentLimit($config, $docIds), + $divisionId, + $customerId, + $docIds + ); + $invoiceGroups = []; + foreach ($invoices as $invoice) { + $xml = call_user_func($this->xmlBuilder, $invoice); + if (is_array($xml) && isset($xml['error'])) { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $invoice['id'], + 'error' => $xml['error'], + ]; + continue; + } + if (!is_string($xml) || trim($xml) === '') { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $invoice['id'], + 'error' => 'Empty KSeF XML document.', + ]; + continue; + } + try { + $this->gateway->validateXml($xml); + } catch (\Throwable $e) { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $invoice['id'], + 'error' => $e->getMessage(), + ]; + continue; + } + + $sellerTen = preg_replace('/[^0-9]/', '', $invoice['division_ten'] ?? $invoice['div_ten'] ?? ''); + if ($sellerTen === '') { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $invoice['id'], + 'error' => 'Missing seller TEN.', + ]; + continue; + } + + $groupDivisionId = isset($invoice['divisionid']) ? (int) $invoice['divisionid'] : null; + $groupKey = ($groupDivisionId === null ? 'global' : $groupDivisionId) . ':' . $sellerTen; + if (!isset($invoiceGroups[$groupKey])) { + $invoiceGroups[$groupKey] = [ + 'division_id' => $groupDivisionId, + 'seller_ten' => $sellerTen, + 'invoices' => [], + ]; + } + + $invoiceGroups[$groupKey]['invoices'][] = [ + 'invoice' => $invoice, + 'xml' => $xml, + 'hash' => $this->invoiceHash($xml), + ]; + } + + foreach ($invoiceGroups as $invoiceGroup) { + $sellerTen = $invoiceGroup['seller_ten']; + $preparedInvoices = $invoiceGroup['invoices']; + $groupConfig = $this->configForDivision($invoiceGroup['division_id'], $config); + $reserved = null; + $documents = []; + foreach ($preparedInvoices as $preparedInvoice) { + $documents[] = [ + 'docid' => (int) $preparedInvoice['invoice']['id'], + 'hash' => $preparedInvoice['hash'], + ]; + } + + try { + $reserved = $this->repository->reserveInvoices( + $documents, + $groupConfig->getEnvironment(), + time() + ); + + if (empty($reserved['documents'])) { + $this->addReservationSkippedErrors($result, $reserved, $preparedInvoices); + continue; + } + + foreach ($reserved['skipped'] as $docId => $error) { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $docId, + 'error' => $error, + ]; + } + + $reservedDocIds = []; + foreach ($reserved['documents'] as $document) { + $reservedDocIds[(int) $document['docid']] = true; + } + + $xmlDocuments = []; + foreach ($preparedInvoices as $preparedInvoice) { + if (isset($reservedDocIds[(int) $preparedInvoice['invoice']['id']])) { + $xmlDocuments[] = $preparedInvoice['xml']; + } + } + + $sessionReferenceNumber = null; + $closeError = null; + try { + $sessionReferenceNumber = $this->gateway->sendXmlBatch($groupConfig, $sellerTen, $xmlDocuments); + $this->repository->updateSessionReference($reserved['session_id'], $sessionReferenceNumber); + } finally { + if ($sessionReferenceNumber !== null) { + try { + $this->gateway->closeBatchSession($groupConfig, $sellerTen, $sessionReferenceNumber); + } catch (\Throwable $e) { + $closeError = $e; + } + } + } + + if ($closeError !== null) { + foreach ($reserved['documents'] as $document) { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $document['docid'], + 'error' => 'KSeF session close failed: ' . $closeError->getMessage(), + ]; + } + $this->repository->discardSession((int) $reserved['session_id']); + continue; + } + + $this->repository->closeSession($reserved['session_id']); + $result['submitted'] += count($reserved['documents']); + } catch (\Throwable $e) { + if (!empty($reserved['session_id'])) { + $this->repository->discardSession((int) $reserved['session_id']); + } + + $failedInvoices = !empty($reserved['documents']) ? $reserved['documents'] : array_map( + function (array $preparedInvoice): array { + return [ + 'docid' => (int) $preparedInvoice['invoice']['id'], + ]; + }, + $preparedInvoices + ); + foreach ($failedInvoices as $failedInvoice) { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $failedInvoice['docid'], + 'error' => $e->getMessage(), + ]; + } + } + } + + return $result; + } + + public function sync( + KSeFConfig $config, + ?int $divisionId = null, + ?int $customerId = null, + ?array $docIds = null + ): array { + $result = [ + 'updated' => 0, + 'errors' => [], + ]; + + $documents = $this->repository->getPendingDocuments( + $this->getDocumentLimit($config, $docIds), + $divisionId, + $customerId, + $docIds + ); + $invoiceReferenceCache = []; + foreach ($documents as $document) { + try { + $sellerTen = preg_replace('/[^0-9]/', '', $document['seller_ten'] ?? ''); + $documentConfig = $this->configForDivision( + isset($document['divisionid']) ? (int) $document['divisionid'] : null, + $config + ); + $invoiceReferenceNumber = $this->findInvoiceReference( + $documentConfig, + $sellerTen, + $document, + $invoiceReferenceCache + ); + + $status = $this->gateway->getInvoiceStatus( + $documentConfig, + $sellerTen, + $document['session_reference_number'], + $invoiceReferenceNumber + ); + + $statusCode = (int) ($status['status'] ?? self::STATUS_PENDING); + $statusDescription = $status['status_description'] ?? null; + $statusDetails = $status['status_details'] ?? null; + $ksefNumber = $status['ksef_number'] ?? null; + $permanentStorageDate = $this->normalizeStorageDate($status['permanent_storage_date'] ?? null); + if ($statusCode === 440 && !empty($status['original_ksef_number'])) { + $statusCode = self::STATUS_ACCEPTED; + $ksefNumber = $status['original_ksef_number']; + } + + if ($statusCode === self::STATUS_ACCEPTED + && !empty($ksefNumber) + && isset($status['upo']) + && is_string($status['upo']) + && $status['upo'] !== '' + ) { + $this->repository->saveUpo($ksefNumber, $status['upo']); + } + + $this->repository->updateDocumentStatus( + (int) $document['id'], + $statusCode, + $statusDescription, + $statusDetails, + $ksefNumber, + $permanentStorageDate + ); + + $result['updated']++; + } catch (\Throwable $e) { + $result['errors'][] = [ + 'id' => (int) $document['id'], + 'error' => $e->getMessage(), + ]; + } + } + + return $result; + } + + private function configForDivision(?int $divisionId, KSeFConfig $defaultConfig): KSeFConfig + { + if ($this->configProvider === null || $divisionId === null) { + return $defaultConfig; + } + + $config = call_user_func($this->configProvider, $divisionId); + if (!$config instanceof KSeFConfig) { + throw new \RuntimeException('KSeF config provider must return KSeFConfig.'); + } + + return $config; + } + + private function getDocumentLimit(KSeFConfig $config, ?array $docIds): int + { + if ($docIds === null) { + return $config->getMaxDocuments(); + } + + return max(1, count(array_unique(array_map('intval', $docIds)))); + } + + private function addReservationSkippedErrors(array &$result, array $reserved, array $preparedInvoices): void + { + if (!empty($reserved['skipped'])) { + foreach ($reserved['skipped'] as $docId => $error) { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $docId, + 'error' => $error, + ]; + } + + return; + } + + foreach ($preparedInvoices as $preparedInvoice) { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $preparedInvoice['invoice']['id'], + 'error' => 'Invoice is already reserved for KSeF submission.', + ]; + } + } + + private function invoiceHash(string $xml): string + { + return base64_encode(hash('sha256', $xml, true)); + } + + private function findInvoiceReference( + KSeFConfig $config, + string $sellerTen, + array $document, + array &$invoiceReferenceCache + ): string { + $cacheKey = $sellerTen . ':' . $document['session_reference_number']; + if (!array_key_exists($cacheKey, $invoiceReferenceCache)) { + $invoiceReferenceCache[$cacheKey] = $this->waitForInvoiceReferences( + $config, + $sellerTen, + $document['session_reference_number'], + $document + ); + } + $invoiceReferences = $invoiceReferenceCache[$cacheKey]; + if (!empty($invoiceReferences) && $this->findInvoiceReferenceNumber($invoiceReferences, $document) === null) { + $invoiceReferences = $this->waitForInvoiceReferences( + $config, + $sellerTen, + $document['session_reference_number'], + $document + ); + $invoiceReferenceCache[$cacheKey] = $invoiceReferences; + } + + $invoiceReferenceNumber = $this->findInvoiceReferenceNumber($invoiceReferences, $document); + if ($invoiceReferenceNumber !== null) { + return $invoiceReferenceNumber; + } + + throw new \RuntimeException( + 'Couldn\'t find KSeF invoice reference for session ' . $document['session_reference_number'] + . ' and ordinal number ' . $document['ordinalnumber'] . '.' + ); + } + + private function waitForInvoiceReferences( + KSeFConfig $config, + string $sellerTen, + string $sessionReferenceNumber, + array $document + ): array { + $waitedSeconds = 0; + $lastInvoiceReferences = []; + for ($attempt = 0; $attempt === 0 || $waitedSeconds < self::INVOICE_REFERENCE_WAIT_SECONDS; $attempt++) { + if ($attempt > 0) { + $sleepSeconds = self::INVOICE_REFERENCE_RETRY_SECONDS[ + min($attempt - 1, count(self::INVOICE_REFERENCE_RETRY_SECONDS) - 1) + ]; + $sleepSeconds = min($sleepSeconds, self::INVOICE_REFERENCE_WAIT_SECONDS - $waitedSeconds); + call_user_func($this->sleeper, $sleepSeconds); + $waitedSeconds += $sleepSeconds; + } + + $invoiceReferences = $this->gateway->listInvoiceReferences( + $config, + $sellerTen, + $sessionReferenceNumber + ); + $lastInvoiceReferences = $invoiceReferences; + if ($this->findInvoiceReferenceNumber($invoiceReferences, $document) !== null) { + return $invoiceReferences; + } + } + + return $lastInvoiceReferences; + } + + private function findInvoiceReferenceNumber(array $invoiceReferences, array $document): ?string + { + foreach ($invoiceReferences as $invoiceReference) { + if (isset($invoiceReference['ordinal_number']) + && (int) $invoiceReference['ordinal_number'] === (int) $document['ordinalnumber'] + && !empty($invoiceReference['reference_number']) + ) { + return $invoiceReference['reference_number']; + } + } + + if ((int) ($document['session_document_count'] ?? 0) === 1 + && count($invoiceReferences) === 1 + && !empty($invoiceReferences[0]['reference_number']) + ) { + return $invoiceReferences[0]['reference_number']; + } + + return null; + } + + private function normalizeStorageDate(?string $date): ?string + { + if ($date === null || trim($date) === '') { + return null; + } + + try { + return (new \DateTimeImmutable($date))->format('Y-m-d H:i:s'); + } catch (\Exception $e) { + return null; + } + } +} diff --git a/lib/KSeF/N1ebieskiKSeFGateway.php b/lib/KSeF/N1ebieskiKSeFGateway.php new file mode 100644 index 0000000000..8b1efecc03 --- /dev/null +++ b/lib/KSeF/N1ebieskiKSeFGateway.php @@ -0,0 +1,314 @@ +getSchemaPath()) + ), + ]); + } catch (\Throwable $e) { + throw new \RuntimeException($this->formatXmlValidationException($e), 0, $e); + } + } + + public function sendXmlBatch(KSeFConfig $config, string $sellerTen, array $xmlDocuments): string + { + $response = $this->buildClient($config, $sellerTen) + ->sessions() + ->batch() + ->openAndSend($this->createOpenAndSendXmlRequest($xmlDocuments)) + ->object(); + + return $this->readStringProperty($response, 'referenceNumber'); + } + + public function closeBatchSession(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): void + { + $this->buildClient($config, $sellerTen) + ->sessions() + ->batch() + ->close($this->createCloseRequest($sessionReferenceNumber)) + ->status(); + } + + public function listInvoiceReferences(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): array + { + $client = $this->buildClient($config, $sellerTen); + $invoices = []; + $continuationToken = null; + $seenContinuationTokens = []; + + do { + $response = $client + ->sessions() + ->invoices() + ->list($this->createInvoiceListRequest( + $sessionReferenceNumber, + $config->getInvoiceReferencePageSize(), + $continuationToken + )) + ->object(); + + if (!empty($response->invoices) && is_array($response->invoices)) { + foreach ($response->invoices as $invoice) { + if (empty($invoice->referenceNumber) || !is_string($invoice->referenceNumber)) { + continue; + } + + $invoices[] = [ + 'reference_number' => $invoice->referenceNumber, + 'ordinal_number' => isset($invoice->ordinalNumber) ? (int) $invoice->ordinalNumber : null, + ]; + } + } + + $continuationToken = !empty($response->continuationToken) && is_string($response->continuationToken) + ? $response->continuationToken + : null; + if ($continuationToken !== null && isset($seenContinuationTokens[$continuationToken])) { + throw new \RuntimeException('KSeF repeated invoice list continuation token.'); + } + if ($continuationToken !== null) { + $seenContinuationTokens[$continuationToken] = true; + } + } while ($continuationToken !== null); + + return $invoices; + } + + public function getInvoiceStatus( + KSeFConfig $config, + string $sellerTen, + string $sessionReferenceNumber, + string $invoiceReferenceNumber + ): array { + $client = $this->buildClient($config, $sellerTen); + $response = $client + ->sessions() + ->invoices() + ->status([ + 'referenceNumber' => $sessionReferenceNumber, + 'invoiceReferenceNumber' => $invoiceReferenceNumber, + ]) + ->object(); + + $status = $response->status ?? null; + $statusCode = (int) ($status->code ?? 0); + $ksefNumber = $response->ksefNumber ?? null; + $statusDetails = $this->extractStatusDetails($response); + $originalKsefNumber = $response->status->extensions->originalKsefNumber + ?? $this->extractOriginalKsefNumberFromDetails($statusDetails); + $originalSessionReferenceNumber = $response->status->extensions->originalSessionReferenceNumber + ?? $this->extractOriginalSessionReferenceFromDetails($statusDetails); + $upo = null; + + if ($statusCode === KSeFSubmissionService::STATUS_ACCEPTED && !empty($ksefNumber)) { + $upo = $client + ->sessions() + ->invoices() + ->upo([ + 'referenceNumber' => $sessionReferenceNumber, + 'invoiceReferenceNumber' => $invoiceReferenceNumber, + ]) + ->body(); + } + if ($statusCode === 440 && !empty($originalKsefNumber) && !empty($originalSessionReferenceNumber)) { + $upo = $this->fetchOriginalUpo($client, $originalSessionReferenceNumber, $originalKsefNumber); + } + + return [ + 'status' => $statusCode, + 'status_description' => $status->description ?? null, + 'status_details' => $statusDetails, + 'ksef_number' => $ksefNumber, + 'permanent_storage_date' => $this->extractPermanentStorageDate($response), + 'original_ksef_number' => $originalKsefNumber, + 'original_session_reference_number' => $originalSessionReferenceNumber, + 'upo' => $upo, + ]; + } + + private function buildClient(KSeFConfig $config, ?string $sellerTen = null) + { + if (!class_exists('\N1ebieski\KSEFClient\ClientBuilder')) { + throw new \RuntimeException('Missing n1ebieski/ksef-php-client dependency. Run composer install.'); + } + + $builder = (new \N1ebieski\KSEFClient\ClientBuilder()) + ->withMode($this->mode($config)) + ->withEncryptionKey(\N1ebieski\KSEFClient\Factories\EncryptionKeyFactory::makeRandom()) + ->withValidateXml(true); + + if ($sellerTen !== null && $sellerTen !== '') { + $builder = $builder->withIdentifier($sellerTen); + } + + if ($config->getAuthMethod() === KSeFConfig::AUTH_METHOD_TOKEN) { + $builder = $builder->withKsefToken($config->getToken()); + } else { + $builder = $builder->withCertificatePath( + $config->getCertificatePath(), + $config->getCertificatePassword() + ); + } + + return $builder->build(); + } + + private function mode(KSeFConfig $config) + { + switch ($config->getEnvironment()) { + case KSeF::ENVIRONMENT_PROD: + return \N1ebieski\KSEFClient\ValueObjects\Mode::Production; + case KSeF::ENVIRONMENT_DEMO: + return \N1ebieski\KSEFClient\ValueObjects\Mode::Demo; + default: + return \N1ebieski\KSEFClient\ValueObjects\Mode::Test; + } + } + + private function createOpenAndSendXmlRequest(array $xmlDocuments): OpenAndSendXmlRequest + { + return new OpenAndSendXmlRequest(FormCode::Fa3, $xmlDocuments); + } + + private function createCloseRequest(string $sessionReferenceNumber): CloseRequest + { + return new CloseRequest(ReferenceNumber::from($sessionReferenceNumber)); + } + + private function createKsefUpoRequest(string $sessionReferenceNumber, string $ksefNumber): KsefUpoRequest + { + return new KsefUpoRequest( + ReferenceNumber::from($sessionReferenceNumber), + KsefNumber::from($ksefNumber) + ); + } + + private function fetchOriginalUpo($client, string $sessionReferenceNumber, string $ksefNumber): ?string + { + try { + return $client + ->sessions() + ->invoices() + ->ksefUpo($this->createKsefUpoRequest($sessionReferenceNumber, $ksefNumber)) + ->body(); + } catch (\Throwable $e) { + return null; + } + } + + private function formatXmlValidationException(\Throwable $exception): string + { + $message = $exception->getMessage(); + $context = property_exists($exception, 'context') ? $exception->context : null; + $errors = is_array($context) && isset($context['errors']) && is_array($context['errors']) + ? $context['errors'] + : []; + + if (empty($errors)) { + return $message; + } + + $details = []; + foreach ($errors as $error) { + if (!$error instanceof \LibXMLError) { + continue; + } + + $details[] = trim($error->message) + . ' (line ' . $error->line . ', column ' . $error->column . ')'; + } + + return empty($details) + ? $message + : $message . ' ' . implode(' ', $details); + } + + private function createInvoiceListRequest( + string $sessionReferenceNumber, + int $pageSize, + ?string $continuationToken = null + ): array { + $request = [ + 'referenceNumber' => $sessionReferenceNumber, + 'pageSize' => $pageSize, + ]; + + if ($continuationToken !== null) { + $request['continuationToken'] = $continuationToken; + } + + return $request; + } + + private function readStringProperty($object, string $property): string + { + if (!isset($object->{$property}) || !is_string($object->{$property}) || $object->{$property} === '') { + throw new \RuntimeException('KSeF response does not contain ' . $property . '.'); + } + + return $object->{$property}; + } + + private function extractStatusDetails($response): ?string + { + if (!empty($response->status->details)) { + return is_string($response->status->details) + ? $response->status->details + : json_encode($response->status->details); + } + + return null; + } + + private function extractOriginalKsefNumberFromDetails(?string $statusDetails): ?string + { + if ($statusDetails === null) { + return null; + } + + if (preg_match('/\b[0-9]{10}-[0-9]{8}-[A-Z0-9]{12}-[A-Z0-9]{2}\b/i', $statusDetails, $matches)) { + return strtoupper($matches[0]); + } + + return null; + } + + private function extractOriginalSessionReferenceFromDetails(?string $statusDetails): ?string + { + if ($statusDetails === null) { + return null; + } + + if (preg_match('/\b[0-9]{8}-[A-Z]{2}-[A-Z0-9]{10}-[A-Z0-9]{10}-[A-Z0-9]{2}\b/i', $statusDetails, $matches)) { + return strtoupper($matches[0]); + } + + return null; + } + + private function extractPermanentStorageDate($response): ?string + { + foreach (['permanentStorageDate', 'invoicingDate', 'acquisitionTimestamp'] as $field) { + if (!empty($response->{$field})) { + return (string) $response->{$field}; + } + } + + return null; + } +} diff --git a/lib/locale/pl_PL/strings.php b/lib/locale/pl_PL/strings.php index b7da2e55b3..843e47a5d2 100644 --- a/lib/locale/pl_PL/strings.php +++ b/lib/locale/pl_PL/strings.php @@ -6401,6 +6401,22 @@ $_LANG['NO KSeF NUMBER'] = 'BRAK NUMERU KSeF'; $_LANG['KSeF status'] = 'Status KSeF'; +$_LANG['Send to KSeF'] = 'Wyślij KSeF'; +$_LANG['Send invoice to KSeF'] = 'Wyślij fakturę do KSeF'; +$_LANG['Send invoice $a to KSeF?'] = 'Wysłać fakturę $a do KSeF?'; +$_LANG['Send selected invoices to KSeF?'] = 'Wysłać zaznaczone faktury do KSeF?'; +$_LANG['Sending invoices to KSeF. Please wait.'] = 'Wysyłanie faktur do KSeF. Proszę czekać.'; +$_LANG['KSeF invoice handling'] = 'Obsługa faktur KSeF'; +$_LANG['KSeF submitted:'] = 'Wysłano do KSeF:'; +$_LANG['KSeF synchronized:'] = 'Zaktualizowano z KSeF:'; +$_LANG['skipped:'] = 'pominięto:'; +$_LANG['Document is not eligible for KSeF submission or has been submitted already.'] = 'Dokument nie kwalifikuje się do wysyłki do KSeF albo został już wysłany.'; +$_LANG['KSeF accepted'] = 'Zaakceptowano w KSeF'; +$_LANG['waiting for KSeF handling'] = 'oczekuje na przetworzenie w KSeF'; +$_LANG['not submitted to KSeF'] = 'nie wysłano do KSeF'; +$_LANG['UPO not available'] = 'UPO niedostępne'; +$_LANG['Return to invoice list'] = 'Powrót do listy faktur'; +$_LANG['KSeF submission result is not available.'] = 'Wynik wysyłki do KSeF jest niedostępny.'; $_LANG['- any -'] = '- dowolny -'; $_LANG['excluded'] = 'wykluczona'; $_LANG['not sent yet'] = 'jeszcze niewysłana'; diff --git a/modules/invoiceksefinfo.php b/modules/invoiceksefinfo.php index 6d1d9432b9..2031d53f2c 100644 --- a/modules/invoiceksefinfo.php +++ b/modules/invoiceksefinfo.php @@ -25,6 +25,277 @@ */ use \Lms\KSeF\KSeF; +use \Lms\KSeF\KSeFConfig; +use \Lms\KSeF\KSeFRepository; +use \Lms\KSeF\KSeFSubmissionService; +use \Lms\KSeF\N1ebieskiKSeFGateway; + +function invoiceKSeFResultKey() +{ + try { + return bin2hex(random_bytes(8)); + } catch (\Throwable $e) { + return sha1(uniqid('', true)); + } +} + +function invoiceKSeFRenderSendResult(array $result) +{ + $layout['pagetitle'] = trans('KSeF invoice handling'); + $backUrl = $result['backurl'] ?? '?m=invoicelist'; + + echo '

' . $layout['pagetitle'] . '

'; + echo ''; + + if (!empty($result['error'])) { + echo '

' + . htmlspecialchars($result['error'], ENT_QUOTES, 'UTF-8') + . '

'; + echo '

' + . '' + . trans('Return to invoice list') + . '' + . '

'; + return; + } + + $sendResult = $result['send_result']; + $syncResult = $result['sync_result']; + $skippedDocIds = $result['skipped_doc_ids']; + $resultDocuments = $result['result_documents']; + + $skipped = intval($sendResult['skipped']) + count($skippedDocIds); + echo '

' + . trans('KSeF submitted:') . ' ' . intval($sendResult['submitted']) + . ', ' . trans('KSeF synchronized:') . ' ' . intval($syncResult['updated']) + . ', ' . trans('skipped:') . ' ' . $skipped + . '

'; + + $sendErrors = []; + foreach ($sendResult['errors'] as $error) { + $sendErrors[(int) $error['docid']][] = $error['error']; + } + $syncErrors = []; + foreach ($syncResult['errors'] as $error) { + $syncErrors[(int) $error['id']][] = $error['error']; + } + $skippedMap = array_fill_keys($skippedDocIds, true); + + if (!empty($resultDocuments)) { + echo ''; + echo '' + . '' + . '' + . '' + . '' + . ''; + foreach ($resultDocuments as $document) { + $docId = (int) $document['id']; + $ksefDocumentId = (int) $document['ksefdocumentid']; + $statusMessages = []; + $statusClass = ''; + + if (isset($skippedMap[$docId])) { + $statusMessages[] = trans('Document is not eligible for KSeF submission or has been submitted already.'); + $statusClass = 'red'; + } + if (!empty($sendErrors[$docId])) { + $statusMessages = array_merge($statusMessages, $sendErrors[$docId]); + $statusClass = 'red'; + } + if ($ksefDocumentId && !empty($syncErrors[$ksefDocumentId])) { + $statusMessages = array_merge($statusMessages, $syncErrors[$ksefDocumentId]); + $statusClass = 'red'; + } + if (empty($statusMessages)) { + if ((int) $document['status'] === KSeFSubmissionService::STATUS_ACCEPTED) { + $statusMessages[] = $document['statusdescription'] ?: trans('KSeF accepted'); + } elseif (isset($document['status'])) { + $statusMessages[] = ($document['statusdescription'] ?: trans('waiting for KSeF handling')) + . ' (' . intval($document['status']) . ')'; + $statusDetails = KSeF::formatStatusDetails($document['statusdetails']); + if (!empty($statusDetails)) { + $statusMessages[] = $statusDetails; + } + } else { + $statusMessages[] = trans('not submitted to KSeF'); + } + } + + $upo = '-'; + if (!empty($document['ksefnumber']) && KSeF::upoFileExists($document['ksefnumber'])) { + $upo = '' + . trans('Download UPO') + . '' + . ' | ' + . '' + . trans('View UPO') + . ''; + } elseif ((int) $document['status'] === KSeFSubmissionService::STATUS_ACCEPTED) { + $upo = trans('UPO not available'); + } + + echo '' + . '' + . '' + . '' + . '' + . ''; + } + echo '
' . trans('Document') . '' . trans('Status') . '' . trans('KSeF number') . '' . trans('UPO') . '
' + . htmlspecialchars($document['fullnumber'], ENT_QUOTES, 'UTF-8') + . '' + . htmlspecialchars(implode(' ', $statusMessages), ENT_QUOTES, 'UTF-8') + . '' + . htmlspecialchars($document['ksefnumber'] ?: '-', ENT_QUOTES, 'UTF-8') + . '' . $upo . '
'; + } + + echo '

' + . '' + . trans('Return to invoice list') + . '' + . '

'; +} + +if (!empty($_GET['action']) && $_GET['action'] == 'send-result') { + if (!ConfigHelper::checkPrivileges('finances_management', 'financial_operations')) { + die('Access denied.'); + } + + $resultKey = preg_replace('/[^a-f0-9]/', '', $_GET['key'] ?? ''); + $result = null; + if ($resultKey !== '') { + $resultSessionKey = 'invoiceksefresult.' . $resultKey; + $SESSION->restore($resultSessionKey, $result); + $SESSION->remove($resultSessionKey); + } + + $layout['pagetitle'] = trans('KSeF invoice handling'); + $SMARTY->display('header.html'); + if (empty($result) || !is_array($result)) { + invoiceKSeFRenderSendResult([ + 'backurl' => '?m=invoicelist', + 'error' => trans('KSeF submission result is not available.'), + ]); + } else { + invoiceKSeFRenderSendResult($result); + } + $SMARTY->display('footer.html'); + die; +} + +if (!empty($_GET['action']) && $_GET['action'] == 'send') { + if (!ConfigHelper::checkPrivileges('finances_management', 'financial_operations')) { + die('Access denied.'); + } + if ($_SERVER['REQUEST_METHOD'] != 'POST') { + die('Invalid request method.'); + } + + set_time_limit(0); + + if (!empty($_GET['id'])) { + $docIds = [ + intval($_GET['id']), + ]; + } elseif (isset($_POST['marks']) && is_array($_POST['marks'])) { + $docIds = Utils::filterIntegers($_POST['marks']); + } else { + $docIds = []; + } + $docIds = array_values(array_unique(array_filter(array_map('intval', $docIds)))); + if (empty($docIds)) { + die('No invoices selected.'); + } + $backUrl = '?m=invoicelist'; + if (!empty($_POST['backurl']) && is_string($_POST['backurl']) + && preg_match('/^\?m=invoicelist(?:[&#]|$)/', $_POST['backurl'])) { + $backUrl = $_POST['backurl']; + } + + $result = [ + 'backurl' => $backUrl, + ]; + try { + $section = 'ksef'; + $repository = new KSeFRepository($DB); + $configProvider = function (?int $divisionId = null) use ($section) { + if ($divisionId !== null) { + ConfigHelper::setFilter($divisionId); + } + + return KSeFConfig::fromConfigHelper($section, true); + }; + $config = KSeFConfig::fromConfigHelper($section, false); + $ksef = new KSeF($DB, $LMS); + $service = new KSeFSubmissionService( + $repository, + new N1ebieskiKSeFGateway(), + function (array $invoice) use ($LMS, $ksef) { + $invoiceContent = $LMS->GetInvoiceContent((int) $invoice['id']); + if (empty($invoiceContent)) { + return ['error' => 'Invoice not found.']; + } + + return $ksef->getInvoiceXml($invoiceContent); + }, + $configProvider + ); + + $selectedDocumentLimit = count($docIds); + $eligibleInvoices = $repository->getEligibleInvoices($selectedDocumentLimit, null, null, $docIds); + $pendingDocuments = $repository->getPendingDocuments($selectedDocumentLimit, null, null, $docIds); + $actionableDocIds = []; + foreach ($eligibleInvoices as $invoice) { + $actionableDocIds[(int) $invoice['id']] = true; + } + foreach ($pendingDocuments as $document) { + $actionableDocIds[(int) $document['docid']] = true; + } + + $sendResult = $service->send($config, null, null, $docIds); + $syncResult = [ + 'updated' => 0, + 'errors' => [], + ]; + if ($sendResult['submitted'] > 0 || !empty($pendingDocuments)) { + $syncResult = $service->sync($config, null, null, $docIds); + } + $skippedDocIds = array_values(array_diff($docIds, array_keys($actionableDocIds))); + $result['send_result'] = $sendResult; + $result['sync_result'] = $syncResult; + $result['skipped_doc_ids'] = $skippedDocIds; + $result['result_documents'] = $DB->GetAll( + 'SELECT + d.id, + d.fullnumber, + kd.id AS ksefdocumentid, + kd.status, + kd.statusdescription, + kd.statusdetails, + kd.ksefnumber + FROM documents d + LEFT JOIN ( + SELECT docid, MAX(id) AS maxid + FROM ksefdocuments + GROUP BY docid + ) latestkd ON latestkd.docid = d.id + LEFT JOIN ksefdocuments kd ON kd.id = latestkd.maxid + WHERE d.id IN (' . implode(',', $docIds) . ') + ORDER BY d.id' + ) ?: []; + } catch (\Throwable $e) { + $result['error'] = $e->getMessage(); + } + + $resultKey = invoiceKSeFResultKey(); + $SESSION->save('invoiceksefresult.' . $resultKey, $result); + $SESSION->redirect('?m=invoiceksefinfo&action=send-result&key=' . $resultKey); +} if (!empty($_GET['purchase'])) { $doc = $DB->GetRow( @@ -147,6 +418,8 @@ $_GET['id'], ] )) { + $doc['ksefstatusdetails'] = KSeF::formatStatusDetails($doc['ksefstatusdetails']); + if (!empty($_GET['action'])) { $action = $_GET['action']; switch ($action) { diff --git a/templates/default/invoice/invoiceksefinfo.html b/templates/default/invoice/invoiceksefinfo.html index 35ff16bc12..91bc89efa9 100644 --- a/templates/default/invoice/invoiceksefinfo.html +++ b/templates/default/invoice/invoiceksefinfo.html @@ -169,7 +169,7 @@ {t a=$invoice.ksefstatusdescription b=$invoice.status}$a (error code: $b){/t}
- ({$invoice.ksefstatusdetails}) + ({$invoice.ksefstatusdetails|escape})
{/if} diff --git a/templates/default/invoice/invoicelist.html b/templates/default/invoice/invoicelist.html index 96868cc813..5d3eae19e8 100644 --- a/templates/default/invoice/invoicelist.html +++ b/templates/default/invoice/invoicelist.html @@ -271,6 +271,15 @@

{$layout.pagetitle}

{/if} {hint icon="lock" url="?m=invoiceksefinfo&id={$invoiceid}" tooltip_class="lms-ui-ksef-qr-code" class=$class} {/if} + {if !$invoice.cancelled && !empty($invoice.ksefsubmit) + && (empty($invoice.ksefhash) || !empty($invoice.ksefstatus) && $invoice.ksefstatus != 0 && $invoice.ksefstatus != 200)} + {if ConfigHelper::checkPrivilege('finances_management') || ConfigHelper::checkPrivilege('financial_operations')} + {button type="link" icon="upload" class="send-ksef-invoice" + href="#" + data_href="?m=invoiceksefinfo&id={$invoice.id}&action=send" + tip="Send invoice to KSeF"} + {/if} + {/if} {if $invoice.type == $smarty.const.DOC_INVOICE_PRO && !$invoice.closed} {if ConfigHelper::checkPrivilege('finances_management') || ConfigHelper::checkPrivilege('financial_operations')} {button type="link" icon="transform" href="?m=invoicenew&id={$invoice.id}&action=init" @@ -479,6 +488,9 @@

{$layout.pagetitle}

{else} {button icon="add" label="New Pro Forma" href="?m=invoicenew{if $listdata.cat == 'customerid'}&customerid={$listdata.search}{/if}&action=init&proforma=1"} {/if} + {if ConfigHelper::checkPrivilege('finances_management') || ConfigHelper::checkPrivilege('financial_operations')} + {button icon="upload" id="send-ksef-invoices" label="Send to KSeF"} + {/if} {button icon="mail" id="send-invoices" label="Send invoices"} {button icon="delete" id="delete-invoices" label="Delete"} {if ConfigHelper::checkPrivilege('trade_document_archiving')} @@ -676,6 +688,26 @@

{$layout.pagetitle}

return false; }); + function showKSeFSendProgressDialog() { + var progressDeferred = progressDialog($t("Sending invoices to KSeF. Please wait.")); + + $(window).one('pagehide', function() { + progressDeferred.reject(); + }); + } + + $('.send-ksef-invoice').click(function () { + var number = $(this).closest('tr').attr('data-number'); + confirmDialog($t("Send invoice $a to KSeF?", number), this).done(function () { + var form = $('
'); + form.attr('action', $(this).attr('data-href')); + form.append($('').val(location.search + location.hash)); + showKSeFSendProgressDialog(); + form.appendTo('body').submit().remove(); + }); + return false; + }); + $('#account-invoices').click(function () { if (!$(this).closest('tfoot').prev('.lms-ui-multi-check').find('input.lms-ui-multi-check:checked').length) { alertDialog($t('No document of given type has been selected!'), this); @@ -715,6 +747,22 @@

{$layout.pagetitle}

}); }); + $('#send-ksef-invoices').click(function () { + if (!$(this).closest('tfoot').prev('.lms-ui-multi-check').find('input.lms-ui-multi-check:checked').length) { + alertDialog($t('No document of given type has been selected!'), this); + return; + } + + confirmDialog($t("Send selected invoices to KSeF?"), this).done(function () { + $(document.page).find('input[name="backurl"]').remove(); + $(document.page).append($('').val(location.search + location.hash)); + document.page.action = "?m=invoiceksefinfo&action=send"; + document.page.target = ""; + showKSeFSendProgressDialog(); + document.page.submit(); + }); + }); + $('#archive-invoices').click(function () { if (!$(this).closest('tfoot').prev('.lms-ui-multi-check').find('input.lms-ui-multi-check:checked').length) { alertDialog($t('No document of given type has been selected!'), this); diff --git a/tests/lib/KSeF/ConfigHelper.php b/tests/lib/KSeF/ConfigHelper.php new file mode 100644 index 0000000000..3db89085e5 --- /dev/null +++ b/tests/lib/KSeF/ConfigHelper.php @@ -0,0 +1,11 @@ +invalidXmlDocuments[$xml])) { + throw new \RuntimeException($this->invalidXmlDocuments[$xml]); + } + } + + public function sendXmlBatch(KSeFConfig $config, string $sellerTen, array $xmlDocuments): string + { + $this->sentXmlBatches[] = $xmlDocuments; + $this->sentConfigs[] = [ + 'environment' => $config->getEnvironment(), + 'token' => $config->getToken(), + ]; + + return 'SESSION-' . count($this->sentXmlBatches); + } + + public function closeBatchSession(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): void + { + if ($this->failClose) { + throw new \RuntimeException('Close failed'); + } + + $this->closedBatchSessions[] = $sessionReferenceNumber; + } + + public function listInvoiceReferences(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): array + { + $this->listedSessions[] = $sessionReferenceNumber; + $this->listedConfigs[] = [ + 'environment' => $config->getEnvironment(), + 'token' => $config->getToken(), + ]; + if (!empty($this->emptyInvoiceReferenceResponses[$sessionReferenceNumber])) { + $this->emptyInvoiceReferenceResponses[$sessionReferenceNumber]--; + + return []; + } + if (!empty($this->invoiceReferenceResponseSequences[$sessionReferenceNumber])) { + return array_shift($this->invoiceReferenceResponseSequences[$sessionReferenceNumber]); + } + + return $this->sessionInvoiceReferences[$sessionReferenceNumber] ?? []; + } + + public function getInvoiceStatus( + KSeFConfig $config, + string $sellerTen, + string $sessionReferenceNumber, + string $invoiceReferenceNumber + ): array { + $this->statusConfigs[] = [ + 'environment' => $config->getEnvironment(), + 'token' => $config->getToken(), + ]; + + return $this->invoiceStatuses[$sessionReferenceNumber . ':' . $invoiceReferenceNumber]; + } +} diff --git a/tests/lib/KSeF/FakeKSeFLms.php b/tests/lib/KSeF/FakeKSeFLms.php new file mode 100644 index 0000000000..d97ae0d1e0 --- /dev/null +++ b/tests/lib/KSeF/FakeKSeFLms.php @@ -0,0 +1,32 @@ + '', + 'phone' => '', + 'rbe' => '', + 'regon' => '', + ]; + } + + public function GetTaxes() + { + return [ + 1 => [ + 'value' => 23, + 'reversecharge' => 0, + 'taxed' => 1, + ], + ]; + } + + public function getCustomerBalance() + { + return 0; + } +} diff --git a/tests/lib/KSeF/FakeKSeFRepository.php b/tests/lib/KSeF/FakeKSeFRepository.php new file mode 100644 index 0000000000..ca56ffb273 --- /dev/null +++ b/tests/lib/KSeF/FakeKSeFRepository.php @@ -0,0 +1,177 @@ +eligibleInvoices = $eligibleInvoices; + $this->pendingDocuments = $pendingDocuments; + } + + public function getEligibleInvoices( + int $limit, + ?int $divisionId = null, + ?int $customerId = null, + ?array $docIds = null + ): array { + $this->eligibleDocIds = $docIds; + $eligibleInvoices = $this->eligibleInvoices; + if ($docIds !== null) { + $eligibleInvoices = array_filter( + $eligibleInvoices, + function (array $invoice) use ($docIds): bool { + return in_array((int) $invoice['id'], $docIds, true); + } + ); + } + + return array_slice(array_values($eligibleInvoices), 0, $limit); + } + + public function reserveInvoices(array $documents, int $environment, int $createdAt): array + { + if ($this->reservationFails) { + return [ + 'skipped' => [], + 'documents' => [], + ]; + } + if (!empty($this->reservedSkipped)) { + return [ + 'skipped' => $this->reservedSkipped, + 'documents' => [], + ]; + } + + $sessionReferenceNumber = 'LOCAL-' . $documents[0]['docid']; + $this->sessions[] = [ + 'reference_number' => $sessionReferenceNumber, + 'environment' => $environment, + 'created_at' => $createdAt, + ]; + $sessionId = count($this->sessions); + $reservedDocuments = []; + foreach ($documents as $index => $document) { + $this->documents[] = [ + 'sessionid' => $sessionId, + 'docid' => (int) $document['docid'], + 'ordinalnumber' => $index + 1, + 'hash' => $document['hash'], + 'status' => 0, + 'statusdescription' => 'Reserved for KSeF submission.', + 'statusdetails' => null, + ]; + $reservedDocuments[] = [ + 'docid' => (int) $document['docid'], + 'document_id' => count($this->documents), + 'ordinalnumber' => $index + 1, + ]; + } + + return [ + 'session_id' => $sessionId, + 'session_reference_number' => $sessionReferenceNumber, + 'documents' => $reservedDocuments, + 'skipped' => [], + ]; + } + + public function updateSessionReference(int $id, string $referenceNumber): void + { + if ($this->failSessionReferenceUpdate) { + throw new \RuntimeException('Session reference update failed'); + } + + $this->sessionReferenceUpdates[] = [ + 'id' => $id, + 'reference_number' => $referenceNumber, + ]; + } + + public function closeSession(int $id): void + { + $this->sessionCloseUpdates[] = [ + 'id' => $id, + ]; + } + + public function discardSession(int $id): void + { + $this->discardedSessions[] = $id; + } + + public function getPendingDocuments( + int $limit, + ?int $divisionId = null, + ?int $customerId = null, + ?array $docIds = null + ): array { + $this->pendingDivisionId = $divisionId; + $this->pendingCustomerId = $customerId; + $this->pendingDocIds = $docIds; + $pendingDocuments = $this->pendingDocuments; + if ($docIds !== null) { + $pendingDocuments = array_filter( + $pendingDocuments, + function (array $document) use ($docIds): bool { + return in_array((int) ($document['docid'] ?? 0), $docIds, true); + } + ); + } + + return array_slice(array_values($pendingDocuments), 0, $limit); + } + + public function updateDocumentStatus( + int $id, + int $status, + ?string $statusDescription, + ?string $statusDetails, + ?string $ksefNumber, + ?string $permanentStorageDate + ): void { + $this->statusUpdates[] = [ + 'id' => $id, + 'status' => $status, + 'status_description' => $statusDescription, + 'status_details' => $statusDetails, + 'ksef_number' => $ksefNumber, + 'permanent_storage_date' => $permanentStorageDate, + ]; + } + + public function saveUpo(string $ksefNumber, string $content): void + { + if ($this->failUpoSave) { + throw new \RuntimeException('UPO save failed'); + } + + $this->savedUpos[] = [ + 'ksef_number' => $ksefNumber, + 'content' => $content, + ]; + } +} diff --git a/tests/lib/KSeF/FakeKsefUpoClient.php b/tests/lib/KSeF/FakeKsefUpoClient.php new file mode 100644 index 0000000000..db0cd16800 --- /dev/null +++ b/tests/lib/KSeF/FakeKsefUpoClient.php @@ -0,0 +1,45 @@ +body = $body; + $this->fail = $fail; + } + + public function sessions() + { + return $this; + } + + public function invoices() + { + return $this; + } + + public function ksefUpo(KsefUpoRequest $request) + { + if ($this->fail) { + throw new \RuntimeException('UPO API failed'); + } + + $this->request = $request; + + return $this; + } + + public function body() + { + return $this->body; + } +} diff --git a/tests/lib/KSeF/FakeXmlValidationException.php b/tests/lib/KSeF/FakeXmlValidationException.php new file mode 100644 index 0000000000..05f8356d62 --- /dev/null +++ b/tests/lib/KSeF/FakeXmlValidationException.php @@ -0,0 +1,14 @@ +context = $context; + } +} diff --git a/tests/lib/KSeF/KSeFConfigTest.php b/tests/lib/KSeF/KSeFConfigTest.php new file mode 100644 index 0000000000..515a2486fd --- /dev/null +++ b/tests/lib/KSeF/KSeFConfigTest.php @@ -0,0 +1,125 @@ + 'test', + 'auth_method' => 'token', + 'token' => 'secret-token', + 'max_documents' => '25', + ]); + + $this->assertSame(KSeF::ENVIRONMENT_TEST, $config->getEnvironment()); + $this->assertSame('test', $config->getEnvironmentName()); + $this->assertSame('token', $config->getAuthMethod()); + $this->assertSame('secret-token', $config->getToken()); + $this->assertSame(25, $config->getMaxDocuments()); + } + + public function testBuildsProductionCertificateConfigFromArray() + { + $config = KSeFConfig::fromArray([ + 'environment' => 'production', + 'auth_method' => 'certificate', + 'certificate_path' => '/secure/ksef.p12', + 'certificate_password' => 'cert-password', + ]); + + $this->assertSame(KSeF::ENVIRONMENT_PROD, $config->getEnvironment()); + $this->assertSame('production', $config->getEnvironmentName()); + $this->assertSame('certificate', $config->getAuthMethod()); + $this->assertSame('/secure/ksef.p12', $config->getCertificatePath()); + $this->assertSame('cert-password', $config->getCertificatePassword()); + $this->assertSame(10000, $config->getMaxDocuments()); + } + + public function testInfersTokenAuthWhenTokenIsConfigured() + { + $config = KSeFConfig::fromArray([ + 'environment' => 'test', + 'token' => 'secret-token', + ]); + + $this->assertSame('token', $config->getAuthMethod()); + $this->assertSame('secret-token', $config->getToken()); + } + + public function testRejectsUnknownEnvironment() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported KSeF environment'); + + KSeFConfig::fromArray([ + 'environment' => 'sandbox', + 'auth_method' => 'token', + 'token' => 'secret-token', + ]); + } + + public function testRejectsTokenAuthWithoutToken() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('KSeF token is required'); + + KSeFConfig::fromArray([ + 'environment' => 'test', + 'auth_method' => 'token', + ]); + } + + public function testAllowsCredentialValidationToBeDisabledForDryRun() + { + $config = KSeFConfig::fromArray([ + 'environment' => 'test', + 'auth_method' => 'certificate', + 'max_documents' => 10, + ], false); + + $this->assertSame(KSeF::ENVIRONMENT_TEST, $config->getEnvironment()); + $this->assertSame('certificate', $config->getAuthMethod()); + $this->assertSame(null, $config->getCertificatePath()); + $this->assertSame(10, $config->getMaxDocuments()); + } + + public function testBuildsInvoiceReferencePageSizeWithApiLimit() + { + $config = KSeFConfig::fromArray([ + 'environment' => 'test', + 'auth_method' => 'token', + 'token' => 'secret-token', + 'invoice_reference_page_size' => 2000, + ]); + + $this->assertSame(1000, $config->getInvoiceReferencePageSize()); + } + + public function testBuildsApiBoundedBatchAndPageLimits() + { + $config = KSeFConfig::fromArray([ + 'environment' => 'test', + 'auth_method' => 'token', + 'token' => 'secret-token', + 'max_documents' => 20000, + 'invoice_reference_page_size' => 1, + ]); + + $this->assertSame(10000, $config->getMaxDocuments()); + $this->assertSame(10, $config->getInvoiceReferencePageSize()); + } +} diff --git a/tests/lib/KSeF/KSeFSubmissionServiceTest.php b/tests/lib/KSeF/KSeFSubmissionServiceTest.php new file mode 100644 index 0000000000..f0a517f0ec --- /dev/null +++ b/tests/lib/KSeF/KSeFSubmissionServiceTest.php @@ -0,0 +1,828 @@ +invoice(123), + $this->invoice(124), + ]); + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->send($this->ksefConfig()); + + $this->assertSame(2, $result['submitted']); + $this->assertSame(0, $result['skipped']); + $this->assertSame('LOCAL-123', $repository->sessions[0]['reference_number']); + $this->assertSame(KSeF::ENVIRONMENT_TEST, $repository->sessions[0]['environment']); + $this->assertSame('SESSION-1', $repository->sessionReferenceUpdates[0]['reference_number']); + $this->assertSame(1, count($repository->sessions)); + $this->assertSame(2, count($repository->documents)); + $this->assertSame(123, $repository->documents[0]['docid']); + $this->assertSame(124, $repository->documents[1]['docid']); + $this->assertSame(1, $repository->documents[0]['ordinalnumber']); + $this->assertSame(2, $repository->documents[1]['ordinalnumber']); + $this->assertSame(0, $repository->documents[0]['status']); + $this->assertSame( + base64_encode(hash('sha256', '123', true)), + $repository->documents[0]['hash'] + ); + $this->assertSame([ + '123', + '124', + ], $gateway->sentXmlBatches[0]); + $this->assertSame(['SESSION-1'], $gateway->closedBatchSessions); + $this->assertSame(1, count($repository->sessionCloseUpdates)); + } + + public function testSendUsesDivisionScopedConfigForEachInvoiceGroup() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123, '1234567890', 7), + $this->invoice(124, '1234567890', 8), + ]); + $gateway = new FakeKSeFGateway(); + $service = $this->service( + $repository, + $gateway, + null, + function (?int $divisionId) { + return $divisionId === 8 + ? $this->ksefConfig('production', 'division-8-token') + : $this->ksefConfig('test', 'division-7-token'); + } + ); + + $result = $service->send($this->ksefConfig()); + + $this->assertSame(2, $result['submitted']); + $this->assertSame(2, count($repository->sessions)); + $this->assertSame(KSeF::ENVIRONMENT_TEST, $repository->sessions[0]['environment']); + $this->assertSame(KSeF::ENVIRONMENT_PROD, $repository->sessions[1]['environment']); + $this->assertSame('division-7-token', $gateway->sentConfigs[0]['token']); + $this->assertSame('division-8-token', $gateway->sentConfigs[1]['token']); + } + + public function testSendUsesDivisionScopedConfigWhenDefaultConfigHasNoCredentials() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123, '1234567890', 7), + ]); + $gateway = new FakeKSeFGateway(); + $service = $this->service( + $repository, + $gateway, + null, + function (?int $divisionId) { + return $this->ksefConfig('test', 'division-token'); + } + ); + $selectionConfig = KSeFConfig::fromArray([ + 'environment' => 'test', + 'auth_method' => 'certificate', + ], false); + + $result = $service->send($selectionConfig); + + $this->assertSame(1, $result['submitted']); + $this->assertSame('division-token', $gateway->sentConfigs[0]['token']); + } + + public function testSendCanBeLimitedToSelectedInvoices() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123), + $this->invoice(124), + ]); + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->send($this->ksefConfig(), null, null, [124]); + + $this->assertSame(1, $result['submitted']); + $this->assertSame([124], $repository->eligibleDocIds); + $this->assertSame(124, $repository->documents[0]['docid']); + $this->assertSame(['124'], $gateway->sentXmlBatches[0]); + } + + public function testSendSelectedInvoicesIgnoresConfiguredMaxDocuments() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123), + $this->invoice(124), + ]); + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->send($this->ksefConfig('test', 'secret-token', 1), null, null, [123, 124]); + + $this->assertSame(2, $result['submitted']); + $this->assertSame([123, 124], $repository->eligibleDocIds); + $this->assertSame([ + '123', + '124', + ], $gateway->sentXmlBatches[0]); + } + + public function testSendSkipsInvoiceWhenXmlBuilderReturnsError() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123), + ]); + $gateway = new FakeKSeFGateway(); + $service = $this->service( + $repository, + $gateway, + function () { + return ['error' => 'Invalid buyer TEN']; + } + ); + + $result = $service->send($this->ksefConfig()); + + $this->assertSame(0, $result['submitted']); + $this->assertSame(1, $result['skipped']); + $this->assertSame([], $repository->sessions); + $this->assertSame([], $gateway->sentXmlBatches); + } + + public function testSendSkipsOnlyInvoiceWithInvalidXml() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123), + $this->invoice(124), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->invalidXmlDocuments = [ + '124' => 'Invalid KSeF XML: NIP pattern mismatch.', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->send($this->ksefConfig()); + + $this->assertSame(1, $result['submitted']); + $this->assertSame(1, $result['skipped']); + $this->assertSame(124, $result['errors'][0]['docid']); + $this->assertSame('Invalid KSeF XML: NIP pattern mismatch.', $result['errors'][0]['error']); + $this->assertSame([ + '123', + ], $gateway->sentXmlBatches[0]); + } + + public function testSendSkipsInvoiceWhenReservationFails() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123), + ]); + $repository->reservationFails = true; + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->send($this->ksefConfig()); + + $this->assertSame(0, $result['submitted']); + $this->assertSame(1, $result['skipped']); + $this->assertSame([], $gateway->sentXmlBatches); + } + + public function testSendReportsReservationSkipReasonWhenNoDocumentsWereReserved() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123), + ]); + $repository->reservedSkipped = [ + 123 => 'Invoice disappeared during reservation.', + ]; + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->send($this->ksefConfig()); + + $this->assertSame(0, $result['submitted']); + $this->assertSame(1, $result['skipped']); + $this->assertSame('Invoice disappeared during reservation.', $result['errors'][0]['error']); + $this->assertSame([], $gateway->sentXmlBatches); + } + + public function testSendRemovesLocalReservationWhenCloseFailsAfterXmlWasSent() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->failClose = true; + $service = $this->service($repository, $gateway); + + $result = $service->send($this->ksefConfig()); + + $this->assertSame(0, $result['submitted']); + $this->assertSame(1, $result['skipped']); + $this->assertSame(1, count($result['errors'])); + $this->assertSame(123, $repository->documents[0]['docid']); + $this->assertSame([1], $repository->discardedSessions); + $this->assertSame([], $repository->statusUpdates); + } + + public function testSendClosesBatchSessionWhenLocalSessionReferenceUpdateFails() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123), + ]); + $repository->failSessionReferenceUpdate = true; + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->send($this->ksefConfig()); + + $this->assertSame(0, $result['submitted']); + $this->assertSame(1, $result['skipped']); + $this->assertSame(['SESSION-1'], $gateway->closedBatchSessions); + $this->assertSame([1], $repository->discardedSessions); + $this->assertSame([], $repository->sessionCloseUpdates); + } + + public function testSyncDiscoversInvoiceReferenceByOrdinalNumber() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument([ + 'ordinalnumber' => 2, + 'session_document_count' => 2, + ]), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'ordinal_number' => 1, + 'reference_number' => 'INVOICE-1', + ], + [ + 'ordinal_number' => 2, + 'reference_number' => 'INVOICE-2', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-2'] = [ + 'status' => 200, + 'status_description' => 'Accepted', + 'status_details' => '', + 'ksef_number' => '1234567890-20260424-ABCDEF', + 'permanent_storage_date' => '2026-04-24T10:00:00+02:00', + 'upo' => '', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(1, $result['updated']); + $this->assertSame(10, $repository->statusUpdates[0]['id']); + $this->assertSame('SESSION-1', $gateway->listedSessions[0]); + $this->assertSame(200, $repository->statusUpdates[0]['status']); + $this->assertSame('1234567890-20260424-ABCDEF', $repository->statusUpdates[0]['ksef_number']); + $this->assertSame('2026-04-24 10:00:00', $repository->statusUpdates[0]['permanent_storage_date']); + $this->assertSame('', $repository->savedUpos[0]['content']); + } + + public function testSyncUsesSingleInvoiceReferenceOnlyForSingleDocumentSession() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument(), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'reference_number' => 'INVOICE-1', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-1'] = [ + 'status' => 0, + 'status_description' => 'Processing', + 'status_details' => '', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(1, $result['updated']); + $this->assertSame(0, $repository->statusUpdates[0]['status']); + } + + public function testSyncDoesNotCloseOpenSession() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument([ + 'session_status' => 0, + ]), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'reference_number' => 'INVOICE-1', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-1'] = [ + 'status' => 0, + 'status_description' => 'Processing', + 'status_details' => '', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(1, $result['updated']); + $this->assertSame([], $gateway->closedBatchSessions); + $this->assertSame([], $repository->sessionCloseUpdates); + } + + public function testSyncUpdatesInvoiceStatusesIndependently() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument([ + 'id' => 10, + 'ordinalnumber' => 1, + 'session_document_count' => 2, + ]), + $this->pendingDocument([ + 'id' => 11, + 'ordinalnumber' => 2, + 'session_document_count' => 2, + ]), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'ordinal_number' => 1, + 'reference_number' => 'INVOICE-1', + ], + [ + 'ordinal_number' => 2, + 'reference_number' => 'INVOICE-2', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-1'] = [ + 'status' => 200, + 'status_description' => 'Accepted', + 'status_details' => '', + 'ksef_number' => '1234567890-20260424-ABCDEF', + 'permanent_storage_date' => '2026-04-24T10:00:00+02:00', + 'upo' => '', + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-2'] = [ + 'status' => 450, + 'status_description' => 'Rejected', + 'status_details' => 'Invalid invoice.', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(2, $result['updated']); + $this->assertSame(10, $repository->statusUpdates[0]['id']); + $this->assertSame(200, $repository->statusUpdates[0]['status']); + $this->assertSame(11, $repository->statusUpdates[1]['id']); + $this->assertSame(450, $repository->statusUpdates[1]['status']); + $this->assertSame(null, $repository->statusUpdates[1]['ksef_number']); + $this->assertSame('', $repository->savedUpos[0]['content']); + $this->assertSame(['SESSION-1'], $gateway->listedSessions); + } + + public function testSyncUsesDivisionScopedConfigForPendingDocument() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument([ + 'divisionid' => 8, + ]), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'reference_number' => 'INVOICE-1', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-1'] = [ + 'status' => 0, + 'status_description' => 'Processing', + 'status_details' => '', + ]; + $service = $this->service( + $repository, + $gateway, + null, + function (?int $divisionId) { + return $divisionId === 8 + ? $this->ksefConfig('production', 'division-8-token') + : $this->ksefConfig('test', 'default-token'); + } + ); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(1, $result['updated']); + $this->assertSame('division-8-token', $gateway->listedConfigs[0]['token']); + $this->assertSame('division-8-token', $gateway->statusConfigs[0]['token']); + } + + public function testSyncLimitsPendingDocumentsByDivisionAndCustomer() + { + $repository = new FakeKSeFRepository([], []); + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig(), 8, 123); + + $this->assertSame(0, $result['updated']); + $this->assertSame(8, $repository->pendingDivisionId); + $this->assertSame(123, $repository->pendingCustomerId); + } + + public function testSyncWaitsForInvoiceReferencesWhenTheyAreNotReadyYet() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument(), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->emptyInvoiceReferenceResponses = [ + 'SESSION-1' => 2, + ]; + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'reference_number' => 'INVOICE-1', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-1'] = [ + 'status' => 0, + 'status_description' => 'Processing', + 'status_details' => '', + ]; + $sleeps = []; + $service = $this->service( + $repository, + $gateway, + null, + null, + function (int $seconds) use (&$sleeps) { + $sleeps[] = $seconds; + } + ); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(1, $result['updated']); + $this->assertSame([], $result['errors']); + $this->assertSame(0, $repository->statusUpdates[0]['status']); + $this->assertSame(['SESSION-1', 'SESSION-1', 'SESSION-1'], $gateway->listedSessions); + $this->assertSame([ + 1, + 2, + ], $sleeps); + } + + public function testSyncWaitsForExpectedOrdinalWhenInvoiceReferencesArePartial() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument([ + 'ordinalnumber' => 2, + 'session_document_count' => 2, + ]), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->invoiceReferenceResponseSequences['SESSION-1'] = [ + [ + [ + 'ordinal_number' => 1, + 'reference_number' => 'INVOICE-1', + ], + ], + [ + [ + 'ordinal_number' => 1, + 'reference_number' => 'INVOICE-1', + ], + [ + 'ordinal_number' => 2, + 'reference_number' => 'INVOICE-2', + ], + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-2'] = [ + 'status' => 0, + 'status_description' => 'Processing', + 'status_details' => '', + ]; + $sleeps = []; + $service = $this->service( + $repository, + $gateway, + null, + null, + function (int $seconds) use (&$sleeps) { + $sleeps[] = $seconds; + } + ); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(1, $result['updated']); + $this->assertSame([], $result['errors']); + $this->assertSame([ + 'SESSION-1', + 'SESSION-1', + ], $gateway->listedSessions); + $this->assertSame([ + 1, + ], $sleeps); + $this->assertSame(0, $repository->statusUpdates[0]['status']); + } + + public function testSyncWaitsForMissingInvoiceReferencesOnlyOncePerSession() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument([ + 'id' => 10, + 'docid' => 123, + 'ordinalnumber' => 1, + 'session_document_count' => 2, + ]), + $this->pendingDocument([ + 'id' => 11, + 'docid' => 124, + 'ordinalnumber' => 2, + 'session_document_count' => 2, + ]), + ]); + $gateway = new FakeKSeFGateway(); + $sleeps = []; + $service = $this->service( + $repository, + $gateway, + null, + null, + function (int $seconds) use (&$sleeps) { + $sleeps[] = $seconds; + } + ); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(0, $result['updated']); + $this->assertSame(2, count($result['errors'])); + $expectedLookupCount = $this->expectedInvoiceReferenceLookupCount(); + $this->assertSame($expectedLookupCount, count($gateway->listedSessions)); + $this->assertSame($expectedLookupCount - 1, count($sleeps)); + } + + public function testSyncCanBeLimitedToSelectedInvoices() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument([ + 'docid' => 123, + ]), + $this->pendingDocument([ + 'id' => 11, + 'docid' => 124, + 'session_reference_number' => 'SESSION-2', + ]), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-2'] = [ + [ + 'reference_number' => 'INVOICE-2', + ], + ]; + $gateway->invoiceStatuses['SESSION-2:INVOICE-2'] = [ + 'status' => 0, + 'status_description' => 'Processing', + 'status_details' => '', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig(), null, null, [124]); + + $this->assertSame(1, $result['updated']); + $this->assertSame([124], $repository->pendingDocIds); + $this->assertSame(11, $repository->statusUpdates[0]['id']); + } + + public function testSyncSelectedInvoicesIgnoresConfiguredMaxDocuments() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument([ + 'id' => 10, + 'docid' => 123, + 'session_reference_number' => 'SESSION-1', + ]), + $this->pendingDocument([ + 'id' => 11, + 'docid' => 124, + 'session_reference_number' => 'SESSION-2', + ]), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'reference_number' => 'INVOICE-1', + ], + ]; + $gateway->sessionInvoiceReferences['SESSION-2'] = [ + [ + 'reference_number' => 'INVOICE-2', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-1'] = [ + 'status' => 0, + 'status_description' => 'Processing', + 'status_details' => '', + ]; + $gateway->invoiceStatuses['SESSION-2:INVOICE-2'] = [ + 'status' => 0, + 'status_description' => 'Processing', + 'status_details' => '', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig('test', 'secret-token', 1), null, null, [123, 124]); + + $this->assertSame(2, $result['updated']); + $this->assertSame([123, 124], $repository->pendingDocIds); + $this->assertSame(10, $repository->statusUpdates[0]['id']); + $this->assertSame(11, $repository->statusUpdates[1]['id']); + } + + public function testSyncKeepsDocumentPendingWhenUpoCannotBeSaved() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument(), + ]); + $repository->failUpoSave = true; + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'reference_number' => 'INVOICE-1', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-1'] = [ + 'status' => 200, + 'status_description' => 'Accepted', + 'status_details' => '', + 'ksef_number' => '1234567890-20260424-ABCDEF', + 'permanent_storage_date' => '2026-04-24T10:00:00+02:00', + 'upo' => '', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(0, $result['updated']); + $this->assertSame('UPO save failed', $result['errors'][0]['error']); + $this->assertSame([], $repository->statusUpdates); + } + + public function testSyncTreatsDuplicateInvoiceStatusWithOriginalKsefNumberAsAccepted() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument(), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'reference_number' => 'INVOICE-1', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-1'] = [ + 'status' => 440, + 'status_description' => 'Duplikat faktury', + 'status_details' => 'Duplikat faktury.', + 'original_ksef_number' => '1234567890-20260424-ABCDEF', + 'original_session_reference_number' => '20260424-SO-ORIGINAL', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(1, $result['updated']); + $this->assertSame(200, $repository->statusUpdates[0]['status']); + $this->assertSame('1234567890-20260424-ABCDEF', $repository->statusUpdates[0]['ksef_number']); + $this->assertSame('Duplikat faktury', $repository->statusUpdates[0]['status_description']); + $this->assertSame('Duplikat faktury.', $repository->statusUpdates[0]['status_details']); + $this->assertSame([], $repository->savedUpos); + } + + public function testSyncSavesOriginalUpoForDuplicateInvoiceWhenKsefReturnsIt() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument(), + ]); + $gateway = new FakeKSeFGateway(); + $gateway->sessionInvoiceReferences['SESSION-1'] = [ + [ + 'reference_number' => 'INVOICE-1', + ], + ]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-1'] = [ + 'status' => 440, + 'status_description' => 'Duplikat faktury', + 'status_details' => 'Duplikat faktury.', + 'original_ksef_number' => '1234567890-20260424-ABCDEF', + 'original_session_reference_number' => '20260424-SO-ORIGINAL', + 'upo' => '', + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(1, $result['updated']); + $this->assertSame(200, $repository->statusUpdates[0]['status']); + $this->assertSame('1234567890-20260424-ABCDEF', $repository->statusUpdates[0]['ksef_number']); + $this->assertSame('1234567890-20260424-ABCDEF', $repository->savedUpos[0]['ksef_number']); + $this->assertSame('', $repository->savedUpos[0]['content']); + } + + private function ksefConfig(string $environment = 'test', string $token = 'secret-token', int $maxDocuments = 10000): KSeFConfig + { + return KSeFConfig::fromArray([ + 'environment' => $environment, + 'auth_method' => 'token', + 'token' => $token, + 'max_documents' => $maxDocuments, + ]); + } + + private function invoice(int $id, string $sellerTen = '1234567890', int $divisionId = 7): array + { + return [ + 'id' => $id, + 'divisionid' => $divisionId, + 'division_ten' => $sellerTen, + ]; + } + + private function pendingDocument(array $overrides = []): array + { + return array_merge([ + 'id' => 10, + 'docid' => 123, + 'batchsessionid' => 20, + 'divisionid' => 7, + 'seller_ten' => '1234567890', + 'session_status' => 200, + 'session_reference_number' => 'SESSION-1', + 'ordinalnumber' => 1, + 'session_document_count' => 1, + ], $overrides); + } + + private function service( + FakeKSeFRepository $repository, + FakeKSeFGateway $gateway, + ?callable $xmlBuilder = null, + ?callable $configProvider = null, + ?callable $sleeper = null + ): KSeFSubmissionService { + return new KSeFSubmissionService( + $repository, + $gateway, + $xmlBuilder ?: function (array $invoice) { + return '' . $invoice['id'] . ''; + }, + $configProvider, + $sleeper ?: function () { + } + ); + } + + private function expectedInvoiceReferenceLookupCount(): int + { + $waitedSeconds = 0; + $lookupCount = 1; + for ($attempt = 1; $waitedSeconds < KSeFSubmissionService::INVOICE_REFERENCE_WAIT_SECONDS; $attempt++) { + $sleepSeconds = KSeFSubmissionService::INVOICE_REFERENCE_RETRY_SECONDS[ + min($attempt - 1, count(KSeFSubmissionService::INVOICE_REFERENCE_RETRY_SECONDS) - 1) + ]; + $waitedSeconds += min( + $sleepSeconds, + KSeFSubmissionService::INVOICE_REFERENCE_WAIT_SECONDS - $waitedSeconds + ); + $lookupCount++; + } + + return $lookupCount; + } +} diff --git a/tests/lib/KSeF/KSeFTest.php b/tests/lib/KSeF/KSeFTest.php new file mode 100644 index 0000000000..ffdd99bee9 --- /dev/null +++ b/tests/lib/KSeF/KSeFTest.php @@ -0,0 +1,277 @@ +ksefXmlGenerator(); + + set_error_handler(function ($severity, $message) { + throw new \ErrorException($message, 0, $severity); + }); + try { + $xml = $ksef->getInvoiceXml($this->invoiceFixture()); + } finally { + restore_error_handler(); + } + + $this->assertStringContainsString('VAT', $xml); + $this->assertStringContainsString('KSeF Test Company', $xml); + } + + public function testSaveUpoContentCreatesMissingStorageDirectory() + { + $storageDir = STORAGE_DIR . DIRECTORY_SEPARATOR . 'ksef'; + $this->removeDirectory($storageDir); + $this->resetKSeFUpoStorageCache(); + + try { + $ksefNumber = '1234567890-20260425-ABCDEF'; + $this->assertFalse(KSeF::upoFileExists($ksefNumber)); + + $result = KSeF::saveUpoContent($ksefNumber, 'test'); + + $this->assertTrue($result); + $this->assertFileExists( + STORAGE_DIR . DIRECTORY_SEPARATOR . 'ksef' + . DIRECTORY_SEPARATOR . 'upo' + . DIRECTORY_SEPARATOR . '1234567890' + . DIRECTORY_SEPARATOR . '20260425' + . DIRECTORY_SEPARATOR . $ksefNumber . '.xml' + ); + } finally { + $this->removeDirectory($storageDir); + $this->resetKSeFUpoStorageCache(); + } + } + + public function testFormatStatusDetailsDecodesJsonUnicodeEscapes() + { + $this->assertSame( + "Nip nabywcy: '6021767728' jest nieprawidłowy.", + KSeF::formatStatusDetails('["Nip nabywcy: \'6021767728\' jest nieprawid\\u0142owy."]') + ); + } + + public function testFormatStatusDetailsFormatsNestedJsonWithoutArrayWarning() + { + $this->assertSame( + '{"field":"NIP","message":"Nieprawidłowy NIP"}', + KSeF::formatStatusDetails('[{"field":"NIP","message":"Nieprawid\\u0142owy NIP"}]') + ); + } + + private function resetKSeFUpoStorageCache() + { + $property = new \ReflectionProperty(KSeF::class, 'upoStorage'); + $property->setAccessible(true); + $property->setValue(null, null); + } + + private function removeDirectory($directory) + { + if (!is_dir($directory)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + rmdir($directory); + } + + private function invoiceFixture() + { + return [ + 'id' => 1, + 'doctype' => DOC_INVOICE, + 'divisionid' => 1, + 'division_ten' => '1234567890', + 'division_name' => 'Seller', + 'division_address' => 'Seller Street 1', + 'division_zip' => '00-001', + 'division_city' => 'Warszawa', + 'division_countryid' => 1, + 'division_footer' => '', + 'div_bank' => '', + 'customerid' => 10, + 'name' => 'KSeF Test Company', + 'address' => 'ul. KSeF Testowa 1', + 'zip' => '00-010', + 'city' => 'Warszawa', + 'countryid' => 1, + 'ten' => '1111111111', + 'cdate' => strtotime('2026-03-25'), + 'sdate' => strtotime('2026-03-25'), + 'pdate' => strtotime('2026-04-08'), + 'fullnumber' => '001/03/2026/fa', + 'currency' => 'PLN', + 'currencyvalue' => 1, + 'taxest' => [ + '23.00' => [ + 'base' => 100.0, + 'tax' => 23.0, + ], + ], + 'taxes' => [], + 'total' => 123.0, + 'netflag' => 0, + 'flags' => [], + 'comment' => '', + 'memo' => '', + 'invoice' => null, + 'content' => [ + [ + 'itemid' => 1, + 'description' => 'Service', + 'content' => '', + 'count' => 1, + 'grossprice' => 123.0, + 'netprice' => 100.0, + 'total' => 123.0, + 'totalbase' => 100.0, + 'totaltax' => 23.0, + 'taxid' => 1, + 'taxcategory' => '', + 'prodid' => '', + ], + ], + 'ksefshowbalancesummary' => 0, + 'ksefxmladdallvalues' => 0, + 'paytype' => PAYTYPE_TRANSFER, + 'account' => '11111111111111111111111111', + 'export' => false, + 'division_bank' => '', + 'bankaccounts' => [], + 'extid' => '', + ]; + } + + private function ksefXmlGenerator() + { + $reflection = new \ReflectionClass(KSeF::class); + $ksef = $reflection->newInstanceWithoutConstructor(); + $this->setKSeFProperty($ksef, 'lms', new FakeKSeFLms()); + $this->setKSeFProperty($ksef, 'divisions', [ + 1 => [ + 'email' => '', + 'phone' => '', + 'rbe' => '', + 'regon' => '', + ], + ]); + $this->setKSeFProperty($ksef, 'countries', [ + 1 => [ + 'ccode' => 'pl_PL', + ], + ]); + $this->setKSeFProperty($ksef, 'defaultCurrency', 'PLN'); + $this->setKSeFProperty($ksef, 'taxes', [ + 1 => [ + 'value' => 23, + 'reversecharge' => 0, + 'taxed' => 1, + ], + ]); + $this->setKSeFProperty($ksef, 'payTypes', [ + PAYTYPE_TRANSFER => 6, + ]); + $this->setKSeFProperty($ksef, 'showOnlyAlternativeAccounts', false); + $this->setKSeFProperty($ksef, 'showAllAccounts', false); + + return $ksef; + } + + private function setKSeFProperty($ksef, $name, $value) + { + $property = new \ReflectionProperty(KSeF::class, $name); + $property->setAccessible(true); + $property->setValue($ksef, $value); + } + } + +} diff --git a/tests/lib/KSeF/LMS.php b/tests/lib/KSeF/LMS.php new file mode 100644 index 0000000000..0119644869 --- /dev/null +++ b/tests/lib/KSeF/LMS.php @@ -0,0 +1,9 @@ +setAccessible(true); + + $request = $method->invoke($gateway, [ + '1', + '2', + ]); + + $this->assertInstanceOf(OpenAndSendXmlRequest::class, $request); + $this->assertSame(FormCode::Fa3, $request->formCode); + $this->assertSame([ + '1', + '2', + ], $request->faktury); + } + + public function testCreatesBatchCloseRequest() + { + $gateway = new N1ebieskiKSeFGateway(); + $method = new \ReflectionMethod($gateway, 'createCloseRequest'); + $method->setAccessible(true); + + $request = $method->invoke($gateway, '20260424-SO-ABCDEFGHIJ-1234567890-AB'); + + $this->assertInstanceOf(CloseRequest::class, $request); + $this->assertSame('20260424-SO-ABCDEFGHIJ-1234567890-AB', $request->referenceNumber->value); + } + + public function testCreatesKsefUpoRequest() + { + $gateway = new N1ebieskiKSeFGateway(); + $method = new \ReflectionMethod($gateway, 'createKsefUpoRequest'); + $method->setAccessible(true); + + $request = $method->invoke( + $gateway, + '20260424-SO-ABCDEFGHIJ-1234567890-AB', + '5130271243-20260424-ABCDEF-123456-AB' + ); + + $this->assertInstanceOf(KsefUpoRequest::class, $request); + $this->assertSame('20260424-SO-ABCDEFGHIJ-1234567890-AB', $request->referenceNumber->value); + $this->assertSame('5130271243-20260424-ABCDEF-123456-AB', $request->ksefNumber->value); + } + + public function testFetchesOriginalUpoForDuplicateInvoice() + { + $gateway = new N1ebieskiKSeFGateway(); + $method = new \ReflectionMethod($gateway, 'fetchOriginalUpo'); + $method->setAccessible(true); + $client = new FakeKsefUpoClient(''); + + $result = $method->invoke( + $gateway, + $client, + '20260424-SO-ABCDEFGHIJ-1234567890-AB', + '5130271243-20260424-ABCDEF-123456-AB' + ); + + $this->assertSame('', $result); + $this->assertInstanceOf(KsefUpoRequest::class, $client->request); + } + + public function testOriginalUpoFetchFailureDoesNotBlockDuplicateRecovery() + { + $gateway = new N1ebieskiKSeFGateway(); + $method = new \ReflectionMethod($gateway, 'fetchOriginalUpo'); + $method->setAccessible(true); + $client = new FakeKsefUpoClient(null, true); + + $result = $method->invoke( + $gateway, + $client, + '20260424-SO-ABCDEFGHIJ-1234567890-AB', + '5130271243-20260424-ABCDEF-123456-AB' + ); + + $this->assertSame(null, $result); + } + + public function testCreatesPaginatedInvoiceListRequest() + { + $gateway = new N1ebieskiKSeFGateway(); + $method = new \ReflectionMethod($gateway, 'createInvoiceListRequest'); + $method->setAccessible(true); + + $request = $method->invoke($gateway, 'SESSION-1', 500, 'NEXT-PAGE'); + + $this->assertSame([ + 'referenceNumber' => 'SESSION-1', + 'pageSize' => 500, + 'continuationToken' => 'NEXT-PAGE', + ], $request); + } + + public function testFormatsXmlValidationErrorsWithLineAndColumn() + { + $gateway = new N1ebieskiKSeFGateway(); + $method = new \ReflectionMethod($gateway, 'formatXmlValidationException'); + $method->setAccessible(true); + $error = new \LibXMLError(); + $error->message = 'Element NIP is not accepted by the pattern.'; + $error->line = 26; + $error->column = 0; + + $result = $method->invoke( + $gateway, + new FakeXmlValidationException('The value is not valid with xsd.', [ + 'errors' => [$error], + ]) + ); + + $this->assertSame( + 'The value is not valid with xsd. Element NIP is not accepted by the pattern. (line 26, column 0)', + $result + ); + } + + public function testExtractsOriginalKsefNumberFromDuplicateStatusDetails() + { + $gateway = new N1ebieskiKSeFGateway(); + $method = new \ReflectionMethod($gateway, 'extractOriginalKsefNumberFromDetails'); + $method->setAccessible(true); + + $result = $method->invoke( + $gateway, + 'Duplikat faktury. Faktura o numerze KSeF: 5265877635-20250626-010080DD2B5E-26 została już prawidłowo przesłana do systemu w sesji: 20250626-SO-2F14610000-242991F8C9-B4' + ); + + $this->assertSame('5265877635-20250626-010080DD2B5E-26', $result); + } + + public function testExtractsOriginalSessionReferenceFromDuplicateStatusDetails() + { + $gateway = new N1ebieskiKSeFGateway(); + $method = new \ReflectionMethod($gateway, 'extractOriginalSessionReferenceFromDetails'); + $method->setAccessible(true); + + $result = $method->invoke( + $gateway, + 'Duplikat faktury. Faktura o numerze KSeF: 5265877635-20250626-010080DD2B5E-26 została już prawidłowo przesłana do systemu w sesji: 20250626-SO-2F14610000-242991F8C9-B4' + ); + + $this->assertSame('20250626-SO-2F14610000-242991F8C9-B4', $result); + } + } + +} diff --git a/tests/lib/KSeF/Utils.php b/tests/lib/KSeF/Utils.php new file mode 100644 index 0000000000..168f0551b1 --- /dev/null +++ b/tests/lib/KSeF/Utils.php @@ -0,0 +1,16 @@ +