From 9dcf801e80bd36e520a827d72fb4e5a0ca3ca40c Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Apr 2026 09:39:08 +0200 Subject: [PATCH 01/17] feat: add KSeF invoice submission backend --- bin/lms-ksef.php | 121 +++ lib/KSeF/KSeF.php | 99 +- lib/KSeF/KSeFConfig.php | 178 +++ lib/KSeF/KSeFGatewayInterface.php | 21 + lib/KSeF/KSeFRepository.php | 327 ++++++ lib/KSeF/KSeFRepositoryInterface.php | 39 + lib/KSeF/KSeFSubmissionService.php | 419 ++++++++ lib/KSeF/N1ebieskiKSeFGateway.php | 314 ++++++ tests/lib/KSeF/KSeFConfigTest.php | 125 +++ tests/lib/KSeF/KSeFSubmissionServiceTest.php | 1014 ++++++++++++++++++ tests/lib/KSeF/KSeFTest.php | 331 ++++++ tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php | 321 ++++++ 12 files changed, 3286 insertions(+), 23 deletions(-) create mode 100755 bin/lms-ksef.php create mode 100644 lib/KSeF/KSeFConfig.php create mode 100644 lib/KSeF/KSeFGatewayInterface.php create mode 100644 lib/KSeF/KSeFRepository.php create mode 100644 lib/KSeF/KSeFRepositoryInterface.php create mode 100644 lib/KSeF/KSeFSubmissionService.php create mode 100644 lib/KSeF/N1ebieskiKSeFGateway.php create mode 100644 tests/lib/KSeF/KSeFConfigTest.php create mode 100644 tests/lib/KSeF/KSeFSubmissionServiceTest.php create mode 100644 tests/lib/KSeF/KSeFTest.php create mode 100644 tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php 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/lib/KSeF/KSeF.php b/lib/KSeF/KSeF.php index 7b0438d60b..1487789a33 100644 --- a/lib/KSeF/KSeF.php +++ b/lib/KSeF/KSeF.php @@ -127,6 +127,24 @@ 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('strval', $decoded)); + } + + return is_scalar($decoded) ? strval($decoded) : $statusDetails; + } + public function __construct($db, $lms) { $this->db = $db; @@ -384,6 +402,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']); } @@ -507,7 +527,7 @@ public function getInvoiceXml(array $invoice) $xml .= "\t\t" . $invoice['customerid'] . "" . PHP_EOL; - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { $buyerUuid = \Ramsey\Uuid\Uuid::uuid4(); $buyerUuid = $buyerUuid->getHex(); $xml .= "\t\t" . $buyerUuid . "" . PHP_EOL; @@ -553,7 +573,7 @@ public function getInvoiceXml(array $invoice) $xml .= "\t" . PHP_EOL; - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { $recipientUuid = \Ramsey\Uuid\Uuid::uuid4(); $recipientUuid = $recipientUuid->getHex(); $xml .= "\t\t" . $recipientUuid . "" . PHP_EOL; @@ -637,7 +657,7 @@ public function getInvoiceXml(array $invoice) $xml .= "\t" . PHP_EOL; - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { $recipientUuid2 = \Ramsey\Uuid\Uuid::uuid4(); $recipientUuid2 = $recipientUuid2->getHex(); $xml .= "\t\t" . $recipientUuid2 . "" . PHP_EOL; @@ -718,7 +738,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'])) { @@ -764,7 +784,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'])) { @@ -810,7 +830,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 { @@ -854,7 +874,7 @@ public function getInvoiceXml(array $invoice) $taxRate = '0.00'; if ($ue || $foreign) { - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest'][$taxRate]) || isset($invoice['invoice']['taxest'][$taxRate])) { if (isset($invoice['taxest'][$taxRate])) { $base = round(($invoice['taxest'][$taxRate]['base'] - (isset($invoice['invoice']['taxest'][$taxRate]) ? $invoice['invoice']['taxest'][$taxRate]['base'] : 0)), 2); @@ -884,7 +904,7 @@ public function getInvoiceXml(array $invoice) } } } else { - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest'][$taxRate]) || isset($invoice['invoice']['taxest'][$taxRate])) { if (isset($invoice['taxest'][$taxRate])) { $base = round(($invoice['taxest'][$taxRate]['base'] - (isset($invoice['invoice']['taxest'][$taxRate]) ? $invoice['invoice']['taxest'][$taxRate]['base'] : 0)), 2); @@ -904,7 +924,7 @@ public function getInvoiceXml(array $invoice) } $taxRate = '-1'; - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest'][$taxRate]) || isset($invoice['invoice']['taxest'][$taxRate])) { if (isset($invoice['taxest'][$taxRate])) { $base = round(($invoice['taxest'][$taxRate]['base'] - (isset($invoice['invoice']['taxest'][$taxRate]) ? $invoice['invoice']['taxest'][$taxRate]['base'] : 0)), 2); @@ -938,7 +958,7 @@ public function getInvoiceXml(array $invoice) } $taxRate = '-2'; - if ($invoice['type'] == DOC_CNOTE) { + if ($invoiceType == DOC_CNOTE) { if (isset($invoice['taxest'][$taxRate]) || isset($invoice['invoice']['taxest'][$taxRate])) { if (isset($invoice['taxest'][$taxRate])) { $base = round(($invoice['taxest'][$taxRate]['base'] - $invoice['invoice']['taxest'][$taxRate]['base']), 2); @@ -970,7 +990,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; @@ -1008,7 +1028,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; @@ -1300,7 +1320,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(' [...]')) . ' [...]'; @@ -1486,7 +1506,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']; @@ -1531,7 +1551,7 @@ public function getInvoiceXml(array $invoice) $xml .= "\t\t\t\t" . date('Y-m-d', $invoice['pdate']) . "" . PHP_EOL; /* if ($currency != $this->defaultCurrency) { - $total = $invoice['type'] == DOC_CNOTE ? $diffTotal : $invoice['total']; + $total = $invoiceType == DOC_CNOTE ? $diffTotal : $invoice['total']; if ($total >= 0) { $xml .= "\t\t\t\tDo zapłaty " . moneyf($total * $currencyValue) . ';' . ' cena umowna ' . moneyf($total, $currency) @@ -2064,11 +2084,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; } @@ -2082,7 +2098,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)) { @@ -2106,7 +2135,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, @@ -2115,12 +2144,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'] + ); + } + $invoiceReferences = $invoiceReferenceCache[$cacheKey]; + + 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']; + } + + 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 { + $waitedSeconds = 0; + 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 + ); + if (!empty($invoiceReferences)) { + return $invoiceReferences; + } + } + + return []; + } + + 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/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..2d265f4b99 --- /dev/null +++ b/tests/lib/KSeF/KSeFSubmissionServiceTest.php @@ -0,0 +1,1014 @@ +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 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; + } +} + +class FakeKSeFRepository implements KSeFRepositoryInterface +{ + public $sessions = []; + public $documents = []; + public $sessionReferenceUpdates = []; + public $sessionCloseUpdates = []; + public $discardedSessions = []; + public $statusUpdates = []; + public $savedUpos = []; + public $reservationFails = false; + public $reservedSkipped = []; + public $failUpoSave = false; + public $failSessionReferenceUpdate = false; + public $eligibleDocIds = null; + public $pendingDivisionId = null; + public $pendingCustomerId = null; + public $pendingDocIds = null; + + private $eligibleInvoices; + private $pendingDocuments; + + public function __construct(array $eligibleInvoices = [], array $pendingDocuments = []) + { + $this->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, + ]; + } +} + +class FakeKSeFGateway implements KSeFGatewayInterface +{ + public $closedBatchSessions = []; + public $sentXmlBatches = []; + public $sentConfigs = []; + public $listedSessions = []; + public $listedConfigs = []; + public $statusConfigs = []; + public $sessionInvoiceReferences = []; + public $invoiceStatuses = []; + public $failClose = false; + public $invalidXmlDocuments = []; + public $emptyInvoiceReferenceResponses = []; + + public function validateXml(string $xml): void + { + if (isset($this->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 []; + } + + 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/KSeFTest.php b/tests/lib/KSeF/KSeFTest.php new file mode 100644 index 0000000000..d9e3bad60c --- /dev/null +++ b/tests/lib/KSeF/KSeFTest.php @@ -0,0 +1,331 @@ +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."]') + ); + } + + 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); + } + } + + class FakeKSeFLms + { + public function GetDivision() + { + return [ + 'email' => '', + '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/N1ebieskiKSeFGatewayTest.php b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php new file mode 100644 index 0000000000..5d78916abd --- /dev/null +++ b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php @@ -0,0 +1,321 @@ +value = $value; + } + + public static function from(string $value): self + { + return new self($value); + } + } + } +} + +namespace N1ebieski\KSEFClient\ValueObjects\Requests\Sessions { + if (!enum_exists(FormCode::class)) { + enum FormCode: string + { + case Fa3 = 'FA (3)'; + } + } +} + +namespace N1ebieski\KSEFClient\Requests\Sessions\Batch\OpenAndSend { + use N1ebieski\KSEFClient\ValueObjects\Requests\Sessions\FormCode; + + if (!class_exists(OpenAndSendXmlRequest::class)) { + final class OpenAndSendXmlRequest + { + public $formCode; + public $faktury; + + public function __construct(FormCode $formCode, array $faktury) + { + $this->formCode = $formCode; + $this->faktury = $faktury; + } + } + } +} + +namespace N1ebieski\KSEFClient\Requests\Sessions\Batch\Close { + use N1ebieski\KSEFClient\ValueObjects\Requests\ReferenceNumber; + + if (!class_exists(CloseRequest::class)) { + final class CloseRequest + { + public $referenceNumber; + + public function __construct(ReferenceNumber $referenceNumber) + { + $this->referenceNumber = $referenceNumber; + } + } + } +} + +namespace N1ebieski\KSEFClient\ValueObjects\Requests { + if (!class_exists(KsefNumber::class)) { + final class KsefNumber + { + public $value; + + public function __construct(string $value) + { + $this->value = $value; + } + + public static function from(string $value): self + { + return new self($value); + } + } + } +} + +namespace N1ebieski\KSEFClient\Requests\Sessions\Invoices\KsefUpo { + use N1ebieski\KSEFClient\ValueObjects\Requests\KsefNumber; + use N1ebieski\KSEFClient\ValueObjects\Requests\ReferenceNumber; + + if (!class_exists(KsefUpoRequest::class)) { + final class KsefUpoRequest + { + public $referenceNumber; + public $ksefNumber; + + public function __construct(ReferenceNumber $referenceNumber, KsefNumber $ksefNumber) + { + $this->referenceNumber = $referenceNumber; + $this->ksefNumber = $ksefNumber; + } + } + } +} + +namespace LMS\Tests\KSeF { + if (!defined('STORAGE_DIR')) { + define('STORAGE_DIR', sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'lms-ksef-test-storage'); + } + + if (!class_exists('PHPUnit\Framework\TestCase') && class_exists('PHPUnit_Framework_TestCase')) { + class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); + } + + use Lms\KSeF\N1ebieskiKSeFGateway; + use N1ebieski\KSEFClient\Requests\Sessions\Batch\Close\CloseRequest; + use N1ebieski\KSEFClient\Requests\Sessions\Batch\OpenAndSend\OpenAndSendXmlRequest; + use N1ebieski\KSEFClient\Requests\Sessions\Invoices\KsefUpo\KsefUpoRequest; + use N1ebieski\KSEFClient\ValueObjects\Requests\Sessions\FormCode; + use PHPUnit\Framework\TestCase; + + class N1ebieskiKSeFGatewayTest extends TestCase + { + public function testCreatesBatchXmlRequestForFa3Documents() + { + $gateway = new N1ebieskiKSeFGateway(); + $method = new \ReflectionMethod($gateway, 'createOpenAndSendXmlRequest'); + $method->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); + } + } + + class FakeXmlValidationException extends \Exception + { + public $context; + + public function __construct(string $message, array $context) + { + parent::__construct($message); + $this->context = $context; + } + } + + class FakeKsefUpoClient + { + public $request; + + private $body; + private $fail; + + public function __construct(?string $body, bool $fail = false) + { + $this->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; + } + } +} From a0cad5026e4d045667ba4ab3d469d31fde54d9be Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Apr 2026 09:39:21 +0200 Subject: [PATCH 02/17] feat: add manual KSeF invoice submission UI --- js/locale/pl_PL.js | 3 + lib/locale/pl_PL/strings.php | 15 + modules/invoiceksefinfo.php | 265 ++++++++++++++++++ .../default/invoice/invoiceksefinfo.html | 2 +- templates/default/invoice/invoicelist.html | 38 +++ 5 files changed, 322 insertions(+), 1 deletion(-) diff --git a/js/locale/pl_PL.js b/js/locale/pl_PL.js index 96f3da97ae..ac735cf0c0 100644 --- a/js/locale/pl_PL.js +++ b/js/locale/pl_PL.js @@ -6374,6 +6374,9 @@ $_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['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/locale/pl_PL/strings.php b/lib/locale/pl_PL/strings.php index 75953d5b27..37d2a08ac0 100644 --- a/lib/locale/pl_PL/strings.php +++ b/lib/locale/pl_PL/strings.php @@ -6415,6 +6415,21 @@ $_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['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..2479d9be94 100644 --- a/modules/invoiceksefinfo.php +++ b/modules/invoiceksefinfo.php @@ -25,6 +25,269 @@ */ 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'] . '

'; + + 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 '' + . '' + . '' + . htmlspecialchars(implode(' ', $statusMessages), ENT_QUOTES, 'UTF-8') + . '' + . '' + . '' + . ''; + } + echo '
' . trans('Document') . '' . trans('Status') . '' . trans('KSeF number') . '' . trans('UPO') . '
' . htmlspecialchars($document['fullnumber'], 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 +410,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..60c77e6142 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,17 @@

{$layout.pagetitle}

return false; }); + $('.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)); + 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 +738,21 @@

{$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 = ""; + 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); From b786b4b59b095a7f7f492d9e592647de7a878201 Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Apr 2026 10:58:14 +0200 Subject: [PATCH 03/17] fix: wait for matching KSeF invoice reference --- lib/KSeF/KSeFSubmissionService.php | 57 ++++++++++++------ tests/lib/KSeF/KSeFSubmissionServiceTest.php | 61 ++++++++++++++++++++ 2 files changed, 101 insertions(+), 17 deletions(-) diff --git a/lib/KSeF/KSeFSubmissionService.php b/lib/KSeF/KSeFSubmissionService.php index 8f06d89bf7..795f026cc1 100644 --- a/lib/KSeF/KSeFSubmissionService.php +++ b/lib/KSeF/KSeFSubmissionService.php @@ -348,25 +348,24 @@ private function findInvoiceReference( $invoiceReferenceCache[$cacheKey] = $this->waitForInvoiceReferences( $config, $sellerTen, - $document['session_reference_number'] + $document['session_reference_number'], + $document ); } $invoiceReferences = $invoiceReferenceCache[$cacheKey]; - - 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 (!empty($invoiceReferences) && $this->findInvoiceReferenceNumber($invoiceReferences, $document) === null) { + $invoiceReferences = $this->waitForInvoiceReferences( + $config, + $sellerTen, + $document['session_reference_number'], + $document + ); + $invoiceReferenceCache[$cacheKey] = $invoiceReferences; } - if ((int) ($document['session_document_count'] ?? 0) === 1 - && count($invoiceReferences) === 1 - && !empty($invoiceReferences[0]['reference_number']) - ) { - return $invoiceReferences[0]['reference_number']; + $invoiceReferenceNumber = $this->findInvoiceReferenceNumber($invoiceReferences, $document); + if ($invoiceReferenceNumber !== null) { + return $invoiceReferenceNumber; } throw new \RuntimeException( @@ -378,9 +377,11 @@ private function findInvoiceReference( private function waitForInvoiceReferences( KSeFConfig $config, string $sellerTen, - string $sessionReferenceNumber + 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[ @@ -396,12 +397,34 @@ private function waitForInvoiceReferences( $sellerTen, $sessionReferenceNumber ); - if (!empty($invoiceReferences)) { + $lastInvoiceReferences = $invoiceReferences; + if ($this->findInvoiceReferenceNumber($invoiceReferences, $document) !== null) { return $invoiceReferences; } } - return []; + 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 diff --git a/tests/lib/KSeF/KSeFSubmissionServiceTest.php b/tests/lib/KSeF/KSeFSubmissionServiceTest.php index 2d265f4b99..384e0aa113 100644 --- a/tests/lib/KSeF/KSeFSubmissionServiceTest.php +++ b/tests/lib/KSeF/KSeFSubmissionServiceTest.php @@ -494,6 +494,63 @@ function (int $seconds) use (&$sleeps) { ], $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([], [ @@ -954,6 +1011,7 @@ class FakeKSeFGateway implements KSeFGatewayInterface public $failClose = false; public $invalidXmlDocuments = []; public $emptyInvoiceReferenceResponses = []; + public $invoiceReferenceResponseSequences = []; public function validateXml(string $xml): void { @@ -994,6 +1052,9 @@ public function listInvoiceReferences(KSeFConfig $config, string $sellerTen, str return []; } + if (!empty($this->invoiceReferenceResponseSequences[$sessionReferenceNumber])) { + return array_shift($this->invoiceReferenceResponseSequences[$sessionReferenceNumber]); + } return $this->sessionInvoiceReferences[$sessionReferenceNumber] ?? []; } From bcb35ded380d01080f2420a9075fbb5ca87f4eca Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Apr 2026 10:58:22 +0200 Subject: [PATCH 04/17] fix: format nested KSeF status details safely --- lib/KSeF/KSeF.php | 13 ++++++++++++- tests/lib/KSeF/KSeFTest.php | 8 ++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/KSeF/KSeF.php b/lib/KSeF/KSeF.php index 1487789a33..725e4d4576 100644 --- a/lib/KSeF/KSeF.php +++ b/lib/KSeF/KSeF.php @@ -139,12 +139,23 @@ public static function formatStatusDetails($statusDetails) } if (is_array($decoded)) { - return implode(', ', array_map('strval', $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; diff --git a/tests/lib/KSeF/KSeFTest.php b/tests/lib/KSeF/KSeFTest.php index d9e3bad60c..0ebe818815 100644 --- a/tests/lib/KSeF/KSeFTest.php +++ b/tests/lib/KSeF/KSeFTest.php @@ -163,6 +163,14 @@ public function testFormatStatusDetailsDecodesJsonUnicodeEscapes() ); } + 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'); From 904114b0bbe55fa094e807f1893682c0f2a3cad7 Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Apr 2026 11:48:47 +0200 Subject: [PATCH 05/17] fix: satisfy KSeF test coding standard --- lib/KSeF/KSeFSubmissionService.php | 9 +- tests/lib/KSeF/ConfigHelper.php | 11 + tests/lib/KSeF/FakeKSeFGateway.php | 82 ++++++ tests/lib/KSeF/FakeKSeFLms.php | 32 +++ tests/lib/KSeF/FakeKSeFRepository.php | 177 ++++++++++++ tests/lib/KSeF/FakeKsefUpoClient.php | 45 ++++ tests/lib/KSeF/FakeXmlValidationException.php | 14 + tests/lib/KSeF/KSeFSubmissionServiceTest.php | 253 +----------------- tests/lib/KSeF/KSeFTest.php | 72 +---- tests/lib/KSeF/LMS.php | 9 + tests/lib/KSeF/Localisation.php | 11 + tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php | 153 +---------- tests/lib/KSeF/Utils.php | 16 ++ 13 files changed, 411 insertions(+), 473 deletions(-) create mode 100644 tests/lib/KSeF/ConfigHelper.php create mode 100644 tests/lib/KSeF/FakeKSeFGateway.php create mode 100644 tests/lib/KSeF/FakeKSeFLms.php create mode 100644 tests/lib/KSeF/FakeKSeFRepository.php create mode 100644 tests/lib/KSeF/FakeKsefUpoClient.php create mode 100644 tests/lib/KSeF/FakeXmlValidationException.php create mode 100644 tests/lib/KSeF/LMS.php create mode 100644 tests/lib/KSeF/Localisation.php create mode 100644 tests/lib/KSeF/Utils.php diff --git a/lib/KSeF/KSeFSubmissionService.php b/lib/KSeF/KSeFSubmissionService.php index 795f026cc1..57471cc0c7 100644 --- a/lib/KSeF/KSeFSubmissionService.php +++ b/lib/KSeF/KSeFSubmissionService.php @@ -34,8 +34,7 @@ public function send( ?int $divisionId = null, ?int $customerId = null, ?array $docIds = null - ): array - { + ): array { $result = [ 'submitted' => 0, 'skipped' => 0, @@ -210,8 +209,7 @@ public function sync( ?int $divisionId = null, ?int $customerId = null, ?array $docIds = null - ): array - { + ): array { $result = [ 'updated' => 0, 'errors' => [], @@ -341,8 +339,7 @@ private function findInvoiceReference( string $sellerTen, array $document, array &$invoiceReferenceCache - ): string - { + ): string { $cacheKey = $sellerTen . ':' . $document['session_reference_number']; if (!array_key_exists($cacheKey, $invoiceReferenceCache)) { $invoiceReferenceCache[$cacheKey] = $this->waitForInvoiceReferences( 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/KSeFSubmissionServiceTest.php b/tests/lib/KSeF/KSeFSubmissionServiceTest.php index 384e0aa113..f0a517f0ec 100644 --- a/tests/lib/KSeF/KSeFSubmissionServiceTest.php +++ b/tests/lib/KSeF/KSeFSubmissionServiceTest.php @@ -10,10 +10,11 @@ class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); } +require_once __DIR__ . '/FakeKSeFGateway.php'; +require_once __DIR__ . '/FakeKSeFRepository.php'; + use Lms\KSeF\KSeF; use Lms\KSeF\KSeFConfig; -use Lms\KSeF\KSeFGatewayInterface; -use Lms\KSeF\KSeFRepositoryInterface; use Lms\KSeF\KSeFSubmissionService; use PHPUnit\Framework\TestCase; @@ -825,251 +826,3 @@ private function expectedInvoiceReferenceLookupCount(): int return $lookupCount; } } - -class FakeKSeFRepository implements KSeFRepositoryInterface -{ - public $sessions = []; - public $documents = []; - public $sessionReferenceUpdates = []; - public $sessionCloseUpdates = []; - public $discardedSessions = []; - public $statusUpdates = []; - public $savedUpos = []; - public $reservationFails = false; - public $reservedSkipped = []; - public $failUpoSave = false; - public $failSessionReferenceUpdate = false; - public $eligibleDocIds = null; - public $pendingDivisionId = null; - public $pendingCustomerId = null; - public $pendingDocIds = null; - - private $eligibleInvoices; - private $pendingDocuments; - - public function __construct(array $eligibleInvoices = [], array $pendingDocuments = []) - { - $this->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, - ]; - } -} - -class FakeKSeFGateway implements KSeFGatewayInterface -{ - public $closedBatchSessions = []; - public $sentXmlBatches = []; - public $sentConfigs = []; - public $listedSessions = []; - public $listedConfigs = []; - public $statusConfigs = []; - public $sessionInvoiceReferences = []; - public $invoiceStatuses = []; - public $failClose = false; - public $invalidXmlDocuments = []; - public $emptyInvoiceReferenceResponses = []; - public $invoiceReferenceResponseSequences = []; - - public function validateXml(string $xml): void - { - if (isset($this->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/KSeFTest.php b/tests/lib/KSeF/KSeFTest.php index 0ebe818815..ffdd99bee9 100644 --- a/tests/lib/KSeF/KSeFTest.php +++ b/tests/lib/KSeF/KSeFTest.php @@ -59,45 +59,11 @@ if (!class_exists('PHPUnit\Framework\TestCase') && class_exists('PHPUnit_Framework_TestCase')) { class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); } - if (!class_exists('Localisation')) { - class Localisation - { - public static function getCurrentCurrency() - { - return 'PLN'; - } - } - } - if (!class_exists('ConfigHelper')) { - class ConfigHelper - { - public static function checkConfig() - { - return false; - } - } - } - if (!class_exists('Utils')) { - class Utils - { - public static function removeHtml($value) - { - return strip_tags($value); - } - - public static function wordWrapToArray($value) - { - return [$value]; - } - } - } - if (!class_exists('LMS')) { - class LMS - { - const SOFTWARE_NAME = 'LMS'; - const SOFTWARE_VERSION = 'test'; - } - } + require_once __DIR__ . '/ConfigHelper.php'; + require_once __DIR__ . '/Localisation.php'; + require_once __DIR__ . '/LMS.php'; + require_once __DIR__ . '/Utils.php'; + require_once __DIR__ . '/FakeKSeFLms.php'; if (!function_exists('bankaccount')) { function bankaccount($customerId, $account) { @@ -308,32 +274,4 @@ private function setKSeFProperty($ksef, $name, $value) } } - class FakeKSeFLms - { - public function GetDivision() - { - return [ - 'email' => '', - '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/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 @@ +value = $value; - } - - public static function from(string $value): self - { - return new self($value); - } - } - } -} - -namespace N1ebieski\KSEFClient\ValueObjects\Requests\Sessions { - if (!enum_exists(FormCode::class)) { - enum FormCode: string - { - case Fa3 = 'FA (3)'; - } - } -} - -namespace N1ebieski\KSEFClient\Requests\Sessions\Batch\OpenAndSend { - use N1ebieski\KSEFClient\ValueObjects\Requests\Sessions\FormCode; - - if (!class_exists(OpenAndSendXmlRequest::class)) { - final class OpenAndSendXmlRequest - { - public $formCode; - public $faktury; - - public function __construct(FormCode $formCode, array $faktury) - { - $this->formCode = $formCode; - $this->faktury = $faktury; - } - } - } -} - -namespace N1ebieski\KSEFClient\Requests\Sessions\Batch\Close { - use N1ebieski\KSEFClient\ValueObjects\Requests\ReferenceNumber; - - if (!class_exists(CloseRequest::class)) { - final class CloseRequest - { - public $referenceNumber; - - public function __construct(ReferenceNumber $referenceNumber) - { - $this->referenceNumber = $referenceNumber; - } - } - } -} - -namespace N1ebieski\KSEFClient\ValueObjects\Requests { - if (!class_exists(KsefNumber::class)) { - final class KsefNumber - { - public $value; - - public function __construct(string $value) - { - $this->value = $value; - } - - public static function from(string $value): self - { - return new self($value); - } - } - } -} - -namespace N1ebieski\KSEFClient\Requests\Sessions\Invoices\KsefUpo { - use N1ebieski\KSEFClient\ValueObjects\Requests\KsefNumber; - use N1ebieski\KSEFClient\ValueObjects\Requests\ReferenceNumber; - - if (!class_exists(KsefUpoRequest::class)) { - final class KsefUpoRequest - { - public $referenceNumber; - public $ksefNumber; - - public function __construct(ReferenceNumber $referenceNumber, KsefNumber $ksefNumber) - { - $this->referenceNumber = $referenceNumber; - $this->ksefNumber = $ksefNumber; - } - } - } -} - namespace LMS\Tests\KSeF { if (!defined('STORAGE_DIR')) { define('STORAGE_DIR', sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'lms-ksef-test-storage'); @@ -109,6 +9,9 @@ public function __construct(ReferenceNumber $referenceNumber, KsefNumber $ksefNu class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); } + require_once __DIR__ . '/FakeKsefUpoClient.php'; + require_once __DIR__ . '/FakeXmlValidationException.php'; + use Lms\KSeF\N1ebieskiKSeFGateway; use N1ebieski\KSEFClient\Requests\Sessions\Batch\Close\CloseRequest; use N1ebieski\KSEFClient\Requests\Sessions\Batch\OpenAndSend\OpenAndSendXmlRequest; @@ -268,54 +171,4 @@ public function testExtractsOriginalSessionReferenceFromDuplicateStatusDetails() } } - class FakeXmlValidationException extends \Exception - { - public $context; - - public function __construct(string $message, array $context) - { - parent::__construct($message); - $this->context = $context; - } - } - - class FakeKsefUpoClient - { - public $request; - - private $body; - private $fail; - - public function __construct(?string $body, bool $fail = false) - { - $this->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/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 @@ + Date: Mon, 27 Apr 2026 12:15:02 +0200 Subject: [PATCH 06/17] fix: show progress while sending KSeF invoices --- js/locale/pl_PL.js | 1 + lib/locale/pl_PL/strings.php | 1 + templates/default/invoice/invoicelist.html | 2 ++ 3 files changed, 4 insertions(+) diff --git a/js/locale/pl_PL.js b/js/locale/pl_PL.js index ac735cf0c0..cf54d1743d 100644 --- a/js/locale/pl_PL.js +++ b/js/locale/pl_PL.js @@ -6376,6 +6376,7 @@ $_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'; diff --git a/lib/locale/pl_PL/strings.php b/lib/locale/pl_PL/strings.php index 37d2a08ac0..b8e9d38f4a 100644 --- a/lib/locale/pl_PL/strings.php +++ b/lib/locale/pl_PL/strings.php @@ -6419,6 +6419,7 @@ $_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:'; diff --git a/templates/default/invoice/invoicelist.html b/templates/default/invoice/invoicelist.html index 60c77e6142..1e6b0f829e 100644 --- a/templates/default/invoice/invoicelist.html +++ b/templates/default/invoice/invoicelist.html @@ -694,6 +694,7 @@

{$layout.pagetitle}

var form = $('
'); form.attr('action', $(this).attr('data-href')); form.append($('').val(location.search + location.hash)); + progressDialog($t("Sending invoices to KSeF. Please wait.")); form.appendTo('body').submit().remove(); }); return false; @@ -749,6 +750,7 @@

{$layout.pagetitle}

$(document.page).append($('').val(location.search + location.hash)); document.page.action = "?m=invoiceksefinfo&action=send"; document.page.target = ""; + progressDialog($t("Sending invoices to KSeF. Please wait.")); document.page.submit(); }); }); From 0385cc6c064e37e28299a27b52ec7ff3a658007a Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Apr 2026 12:55:55 +0200 Subject: [PATCH 07/17] fix: constrain KSeF result status layout --- modules/invoiceksefinfo.php | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/modules/invoiceksefinfo.php b/modules/invoiceksefinfo.php index 2479d9be94..2031d53f2c 100644 --- a/modules/invoiceksefinfo.php +++ b/modules/invoiceksefinfo.php @@ -45,6 +45,10 @@ function invoiceKSeFRenderSendResult(array $result) $backUrl = $result['backurl'] ?? '?m=invoicelist'; echo '

' . $layout['pagetitle'] . '

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

' @@ -81,12 +85,12 @@ function invoiceKSeFRenderSendResult(array $result) $skippedMap = array_fill_keys($skippedDocIds, true); if (!empty($resultDocuments)) { - echo ''; + echo '
'; echo '' - . '' - . '' - . '' - . '' + . '' + . '' + . '' + . '' . ''; foreach ($resultDocuments as $document) { $docId = (int) $document['id']; @@ -135,12 +139,16 @@ function invoiceKSeFRenderSendResult(array $result) } echo '' - . '' - . '' + . '' + . '' - . '' - . '' + . '' + . '' . ''; } echo '
' . trans('Document') . '' . trans('Status') . '' . trans('KSeF number') . '' . trans('UPO') . '' . trans('Document') . '' . trans('Status') . '' . trans('KSeF number') . '' . trans('UPO') . '
' . htmlspecialchars($document['fullnumber'], ENT_QUOTES, 'UTF-8') . '' + . htmlspecialchars($document['fullnumber'], ENT_QUOTES, 'UTF-8') + . '' . htmlspecialchars(implode(' ', $statusMessages), ENT_QUOTES, 'UTF-8') . '' . htmlspecialchars($document['ksefnumber'] ?: '-', ENT_QUOTES, 'UTF-8') . '' . $upo . '' + . htmlspecialchars($document['ksefnumber'] ?: '-', ENT_QUOTES, 'UTF-8') + . '' . $upo . '
'; From f32548273e01798e2796dd2763328adb37a8f93c Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Apr 2026 14:22:30 +0200 Subject: [PATCH 08/17] fix: close KSeF progress dialog on navigation --- templates/default/invoice/invoicelist.html | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/templates/default/invoice/invoicelist.html b/templates/default/invoice/invoicelist.html index 1e6b0f829e..5d3eae19e8 100644 --- a/templates/default/invoice/invoicelist.html +++ b/templates/default/invoice/invoicelist.html @@ -688,13 +688,21 @@

{$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)); - progressDialog($t("Sending invoices to KSeF. Please wait.")); + showKSeFSendProgressDialog(); form.appendTo('body').submit().remove(); }); return false; @@ -750,7 +758,7 @@

{$layout.pagetitle}

$(document.page).append($('').val(location.search + location.hash)); document.page.action = "?m=invoiceksefinfo&action=send"; document.page.target = ""; - progressDialog($t("Sending invoices to KSeF. Please wait.")); + showKSeFSendProgressDialog(); document.page.submit(); }); }); From 9907105b772bf28514e0c421845dbe9ae8b839bf Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Tue, 30 Jun 2026 15:01:40 +0000 Subject: [PATCH 09/17] fix: force C numeric locale in KSeF invoice XML Amounts were serialized with a comma (54,47) under the PL locale, which KSeF rejects on XSD validation (TKwotowy). Force LC_NUMERIC=C while building the XML and restore the previous locale afterwards. --- lib/KSeF/KSeF.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/KSeF/KSeF.php b/lib/KSeF/KSeF.php index 725e4d4576..a3f686b846 100644 --- a/lib/KSeF/KSeF.php +++ b/lib/KSeF/KSeF.php @@ -413,6 +413,9 @@ private function smartFormatNumber($number) public function getInvoiceXml(array $invoice) { + $numericLocale = setlocale(LC_NUMERIC, '0'); + setlocale(LC_NUMERIC, 'C'); + $invoiceType = $invoice['type'] ?? $invoice['doctype'] ?? null; if (!isset($this->divisions[$invoice['divisionid']])) { @@ -1663,6 +1666,10 @@ public function getInvoiceXml(array $invoice) $xml .= "" . PHP_EOL; + if ($numericLocale !== false) { + setlocale(LC_NUMERIC, $numericLocale); + } + return $xml; } From 0c9d1eef326984996070b751ef99453a309278d4 Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Tue, 30 Jun 2026 15:21:11 +0000 Subject: [PATCH 10/17] fix: declare $showMemo property in KSeF class $this->showMemo was assigned in the constructor but never declared, making it a dynamic property. PHP 8.3 emits a deprecation (fatal under strict error handlers / PHP 8.4). Declare it alongside the other show* flags. --- lib/KSeF/KSeF.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/KSeF/KSeF.php b/lib/KSeF/KSeF.php index a3f686b846..330d775a2d 100644 --- a/lib/KSeF/KSeF.php +++ b/lib/KSeF/KSeF.php @@ -124,6 +124,7 @@ class KSeF private $showOnlyAlternativeAccounts; private $showAllAccounts; + private $showMemo; private $smartNumberFormatter; From 5b0a3354833ee81b68bc12b003495af6528e8e67 Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Jul 2026 13:42:41 +0200 Subject: [PATCH 11/17] fix: harden KSeF invoice submission flow --- bin/lms-ksef.php | 11 +- lib/KSeF/KSeF.php | 17 ++- lib/KSeF/KSeFConfig.php | 96 ++++----------- lib/KSeF/KSeFRepository.php | 12 +- lib/KSeF/KSeFSubmissionService.php | 53 +++++--- lib/KSeF/N1ebieskiKSeFGateway.php | 63 ++++++---- modules/invoiceksefinfo.php | 11 +- tests/lib/KSeF/FakeKSeFRepository.php | 4 + tests/lib/KSeF/KSeFConfigTest.php | 50 ++++---- tests/lib/KSeF/KSeFSubmissionServiceTest.php | 121 ++++++++++++++++++- tests/lib/KSeF/KSeFTest.php | 25 ++++ tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php | 15 ++- 12 files changed, 297 insertions(+), 181 deletions(-) diff --git a/bin/lms-ksef.php b/bin/lms-ksef.php index c35484be04..5c7a7811b8 100755 --- a/bin/lms-ksef.php +++ b/bin/lms-ksef.php @@ -23,7 +23,6 @@ 'send' => null, 'sync' => null, 'test' => 't', - 'section:' => 's:', 'division:' => null, 'customerid:' => null, ]; @@ -32,7 +31,6 @@ --send send eligible sales invoices to KSeF; --sync synchronize pending KSeF invoice statuses and UPO files; -t, --test dry run; print candidate counts only; --s, --section= configuration section name, default: ksef; --division= limit sending candidates to selected division; --customerid= limit sending candidates to selected customer; EOF; @@ -53,9 +51,6 @@ $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; @@ -67,14 +62,14 @@ ConfigHelper::setFilter($divisionId); } $customerId = isset($options['customerid']) ? intval($options['customerid']) : null; -$configProvider = function (?int $selectedDivisionId = null) use ($section, $options) { +$configProvider = function (?int $selectedDivisionId = null) use ($options) { if ($selectedDivisionId !== null) { ConfigHelper::setFilter($selectedDivisionId); } - return KSeFConfig::fromConfigHelper($section, !isset($options['test'])); + return KSeFConfig::fromConfigHelper(!isset($options['test'])); }; -$config = KSeFConfig::fromConfigHelper($section, false); +$config = KSeFConfig::fromConfigHelper(false); if (isset($options['test'])) { if ($send) { diff --git a/lib/KSeF/KSeF.php b/lib/KSeF/KSeF.php index 330d775a2d..d4409ddc6d 100644 --- a/lib/KSeF/KSeF.php +++ b/lib/KSeF/KSeF.php @@ -38,6 +38,8 @@ class KSeF const ENVIRONMENT_TEST = 1; const ENVIRONMENT_PROD = 2; const ENVIRONMENT_DEMO = 3; + const STATUS_PENDING = 0; + const STATUS_ACCEPTED = 200; const IDENTIFIER_TEN = 1; const IDENTIFIER_VAT_UE = 2; @@ -417,6 +419,17 @@ public function getInvoiceXml(array $invoice) $numericLocale = setlocale(LC_NUMERIC, '0'); setlocale(LC_NUMERIC, 'C'); + try { + return $this->buildInvoiceXml($invoice); + } finally { + if ($numericLocale !== false) { + setlocale(LC_NUMERIC, $numericLocale); + } + } + } + + private function buildInvoiceXml(array $invoice) + { $invoiceType = $invoice['type'] ?? $invoice['doctype'] ?? null; if (!isset($this->divisions[$invoice['divisionid']])) { @@ -1667,10 +1680,6 @@ public function getInvoiceXml(array $invoice) $xml .= "" . PHP_EOL; - if ($numericLocale !== false) { - setlocale(LC_NUMERIC, $numericLocale); - } - return $xml; } diff --git a/lib/KSeF/KSeFConfig.php b/lib/KSeF/KSeFConfig.php index 64df7874b4..1c4e5560fb 100644 --- a/lib/KSeF/KSeFConfig.php +++ b/lib/KSeF/KSeFConfig.php @@ -4,92 +4,63 @@ class KSeFConfig { - const AUTH_METHOD_TOKEN = 'token'; - const AUTH_METHOD_CERTIFICATE = 'certificate'; - private $environment; - private $environmentName; - private $authMethod; private $token; private $certificatePath; private $certificatePassword; private $maxDocuments; - private $invoiceReferencePageSize; private function __construct( int $environment, - string $environmentName, - string $authMethod, ?string $token, ?string $certificatePath, ?string $certificatePassword, - int $maxDocuments, - int $invoiceReferencePageSize + int $maxDocuments ) { $this->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'); + $environment = self::parseEnvironment($config['environment'] ?? 'test'); $token = self::nullableString($config['token'] ?? null); - $certificatePath = self::nullableString($config['certificate_path'] ?? null); + $certificatePath = self::nullableString($config['certificate_path'] ?? $config['certificate'] ?? 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 ($token === null && $certificatePath !== null && KSeF::isApiToken($certificatePath)) { + $token = $certificatePath; + $certificatePath = null; } - if ($validateCredentials && $authMethod === self::AUTH_METHOD_CERTIFICATE && $certificatePath === null) { - throw new \InvalidArgumentException('KSeF certificate path is required for certificate authentication.'); + if ($validateCredentials && $token === null && $certificatePath === null) { + throw new \InvalidArgumentException('KSeF certificate or API token is required.'); } return new self( $environment, - $environmentName, - $authMethod, $token, $certificatePath, $certificatePassword, - $maxDocuments, - $invoiceReferencePageSize + $maxDocuments ); } - public static function fromConfigHelper(string $section = 'ksef', bool $validateCredentials = true): self + public static function fromConfigHelper(bool $validateCredentials = true): self { - $token = \ConfigHelper::getConfig($section . '.token'); + $certificateOrToken = KSeF::getCertificatePath(); + $legacyToken = \ConfigHelper::getConfig('ksef.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), + 'environment' => \ConfigHelper::getConfig('ksef.environment', 'test'), + 'token' => empty($certificateOrToken) ? $legacyToken : null, + 'certificate_path' => $certificateOrToken, + 'certificate_password' => KSeF::getCertificatePassword(), + 'max_documents' => \ConfigHelper::getConfig('ksef.max_documents', 10000), ], $validateCredentials); } @@ -98,14 +69,9 @@ public function getEnvironment(): int return $this->environment; } - public function getEnvironmentName(): string + public function usesApiToken(): bool { - return $this->environmentName; - } - - public function getAuthMethod(): string - { - return $this->authMethod; + return $this->token !== null; } public function getToken(): ?string @@ -128,26 +94,21 @@ public function getMaxDocuments(): int return $this->maxDocuments; } - public function getInvoiceReferencePageSize(): int - { - return $this->invoiceReferencePageSize; - } - - private static function parseEnvironment($environment): array + private static function parseEnvironment($environment): int { $environment = strtolower(trim((string) $environment)); switch ($environment) { case 'test': case '1': - return [KSeF::ENVIRONMENT_TEST, 'test']; + return KSeF::ENVIRONMENT_TEST; case 'prod': case 'production': case '2': - return [KSeF::ENVIRONMENT_PROD, 'production']; + return KSeF::ENVIRONMENT_PROD; case 'demo': case '3': - return [KSeF::ENVIRONMENT_DEMO, 'demo']; + return KSeF::ENVIRONMENT_DEMO; default: throw new \InvalidArgumentException('Unsupported KSeF environment: ' . $environment); } @@ -164,15 +125,4 @@ private static function nullableString($value): ?string 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/KSeFRepository.php b/lib/KSeF/KSeFRepository.php index 457000981f..b3a4627b8c 100644 --- a/lib/KSeF/KSeFRepository.php +++ b/lib/KSeF/KSeFRepository.php @@ -91,8 +91,8 @@ public function reserveInvoices(array $documents, int $environment, int $created AND (status = ? OR status = ?)', [ $docId, - KSeFSubmissionService::STATUS_PENDING, - KSeFSubmissionService::STATUS_ACCEPTED, + KSeF::STATUS_PENDING, + KSeF::STATUS_ACCEPTED, ] ); if (!empty($alreadyPendingOrAccepted)) { @@ -121,7 +121,7 @@ public function reserveInvoices(array $documents, int $environment, int $created $sessionReferenceNumber, $createdAt, $createdAt, - KSeFSubmissionService::STATUS_PENDING, + KSeF::STATUS_PENDING, 'Reserved for KSeF submission.', $environment, ] @@ -140,7 +140,7 @@ public function reserveInvoices(array $documents, int $environment, int $created $document['docid'], $ordinalNumber, $document['hash'], - KSeFSubmissionService::STATUS_PENDING, + KSeF::STATUS_PENDING, 'Reserved for KSeF submission.', null, ] @@ -190,7 +190,7 @@ public function closeSession(int $id): void statusdescription = ? WHERE id = ?', [ - KSeFSubmissionService::STATUS_ACCEPTED, + KSeF::STATUS_ACCEPTED, 'KSeF session closed.', $id, ] @@ -233,7 +233,7 @@ public function getPendingDocuments( 'kbs.ksefnumber NOT LIKE ?', ]; $params = [ - KSeFSubmissionService::STATUS_PENDING, + KSeF::STATUS_PENDING, 'LOCAL-S-%', ]; diff --git a/lib/KSeF/KSeFSubmissionService.php b/lib/KSeF/KSeFSubmissionService.php index 57471cc0c7..a15c308c6a 100644 --- a/lib/KSeF/KSeFSubmissionService.php +++ b/lib/KSeF/KSeFSubmissionService.php @@ -4,8 +4,6 @@ class KSeFSubmissionService { - const STATUS_PENDING = 0; - const STATUS_ACCEPTED = 200; const INVOICE_REFERENCE_RETRY_SECONDS = [1, 2, 3, 5, 10]; const INVOICE_REFERENCE_WAIT_SECONDS = 600; @@ -14,6 +12,7 @@ class KSeFSubmissionService private $xmlBuilder; private $configProvider; private $sleeper; + private $divisionConfigs = []; public function __construct( KSeFRepositoryInterface $repository, @@ -41,6 +40,11 @@ public function send( 'errors' => [], ]; + $docIds = $this->normalizeDocumentIds($docIds); + if ($docIds === []) { + return $result; + } + $invoices = $this->repository->getEligibleInvoices( $this->getDocumentLimit($config, $docIds), $divisionId, @@ -109,6 +113,7 @@ public function send( $preparedInvoices = $invoiceGroup['invoices']; $groupConfig = $this->configForDivision($invoiceGroup['division_id'], $config); $reserved = null; + $sessionReferenceStored = false; $documents = []; foreach ($preparedInvoices as $preparedInvoice) { $documents[] = [ @@ -154,6 +159,7 @@ public function send( try { $sessionReferenceNumber = $this->gateway->sendXmlBatch($groupConfig, $sellerTen, $xmlDocuments); $this->repository->updateSessionReference($reserved['session_id'], $sessionReferenceNumber); + $sessionReferenceStored = true; } finally { if ($sessionReferenceNumber !== null) { try { @@ -172,14 +178,13 @@ public function send( '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'])) { + if (!empty($reserved['session_id']) && !$sessionReferenceStored) { $this->repository->discardSession((int) $reserved['session_id']); } @@ -215,6 +220,11 @@ public function sync( 'errors' => [], ]; + $docIds = $this->normalizeDocumentIds($docIds); + if ($docIds === []) { + return $result; + } + $documents = $this->repository->getPendingDocuments( $this->getDocumentLimit($config, $docIds), $divisionId, @@ -225,6 +235,9 @@ public function sync( foreach ($documents as $document) { try { $sellerTen = preg_replace('/[^0-9]/', '', $document['seller_ten'] ?? ''); + if ($sellerTen === '') { + throw new \RuntimeException('Missing seller TEN.'); + } $documentConfig = $this->configForDivision( isset($document['divisionid']) ? (int) $document['divisionid'] : null, $config @@ -243,17 +256,17 @@ public function sync( $invoiceReferenceNumber ); - $statusCode = (int) ($status['status'] ?? self::STATUS_PENDING); + $statusCode = (int) ($status['status'] ?? KSeF::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; + $statusCode = KSeF::STATUS_ACCEPTED; $ksefNumber = $status['original_ksef_number']; } - if ($statusCode === self::STATUS_ACCEPTED + if ($statusCode === KSeF::STATUS_ACCEPTED && !empty($ksefNumber) && isset($status['upo']) && is_string($status['upo']) @@ -289,11 +302,17 @@ private function configForDivision(?int $divisionId, KSeFConfig $defaultConfig): return $defaultConfig; } + if (isset($this->divisionConfigs[$divisionId])) { + return $this->divisionConfigs[$divisionId]; + } + $config = call_user_func($this->configProvider, $divisionId); if (!$config instanceof KSeFConfig) { throw new \RuntimeException('KSeF config provider must return KSeFConfig.'); } + $this->divisionConfigs[$divisionId] = $config; + return $config; } @@ -303,7 +322,7 @@ private function getDocumentLimit(KSeFConfig $config, ?array $docIds): int return $config->getMaxDocuments(); } - return max(1, count(array_unique(array_map('intval', $docIds)))); + return count($docIds); } private function addReservationSkippedErrors(array &$result, array $reserved, array $preparedInvoices): void @@ -350,15 +369,6 @@ private function findInvoiceReference( ); } $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) { @@ -436,4 +446,13 @@ private function normalizeStorageDate(?string $date): ?string return null; } } + + private function normalizeDocumentIds(?array $docIds): ?array + { + if ($docIds === null) { + return null; + } + + return array_values(array_unique(array_filter(array_map('intval', $docIds)))); + } } diff --git a/lib/KSeF/N1ebieskiKSeFGateway.php b/lib/KSeF/N1ebieskiKSeFGateway.php index 8b1efecc03..60f8a327a3 100644 --- a/lib/KSeF/N1ebieskiKSeFGateway.php +++ b/lib/KSeF/N1ebieskiKSeFGateway.php @@ -5,12 +5,22 @@ use N1ebieski\KSEFClient\Requests\Sessions\Batch\Close\CloseRequest; use N1ebieski\KSEFClient\Requests\Sessions\Batch\OpenAndSend\OpenAndSendXmlRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\KsefUpo\KsefUpoRequest; +use N1ebieski\KSEFClient\Requests\Sessions\Invoices\List\ListRequest; +use N1ebieski\KSEFClient\Requests\Sessions\Invoices\Status\StatusRequest; +use N1ebieski\KSEFClient\Requests\Sessions\Invoices\Upo\UpoRequest; +use N1ebieski\KSEFClient\Support\Optional; +use N1ebieski\KSEFClient\ValueObjects\Requests\ContinuationToken; use N1ebieski\KSEFClient\ValueObjects\Requests\KsefNumber; use N1ebieski\KSEFClient\ValueObjects\Requests\ReferenceNumber; use N1ebieski\KSEFClient\ValueObjects\Requests\Sessions\FormCode; +use N1ebieski\KSEFClient\ValueObjects\Requests\Sessions\PageSize; class N1ebieskiKSeFGateway implements KSeFGatewayInterface { + const INVOICE_REFERENCE_PAGE_SIZE = 1000; + + private $clients = []; + public function validateXml(string $xml): void { try { @@ -57,7 +67,6 @@ public function listInvoiceReferences(KSeFConfig $config, string $sellerTen, str ->invoices() ->list($this->createInvoiceListRequest( $sessionReferenceNumber, - $config->getInvoiceReferencePageSize(), $continuationToken )) ->object(); @@ -99,10 +108,10 @@ public function getInvoiceStatus( $response = $client ->sessions() ->invoices() - ->status([ - 'referenceNumber' => $sessionReferenceNumber, - 'invoiceReferenceNumber' => $invoiceReferenceNumber, - ]) + ->status(new StatusRequest( + ReferenceNumber::from($sessionReferenceNumber), + ReferenceNumber::from($invoiceReferenceNumber) + )) ->object(); $status = $response->status ?? null; @@ -115,14 +124,14 @@ public function getInvoiceStatus( ?? $this->extractOriginalSessionReferenceFromDetails($statusDetails); $upo = null; - if ($statusCode === KSeFSubmissionService::STATUS_ACCEPTED && !empty($ksefNumber)) { + if ($statusCode === KSeF::STATUS_ACCEPTED && !empty($ksefNumber)) { $upo = $client ->sessions() ->invoices() - ->upo([ - 'referenceNumber' => $sessionReferenceNumber, - 'invoiceReferenceNumber' => $invoiceReferenceNumber, - ]) + ->upo(new UpoRequest( + ReferenceNumber::from($sessionReferenceNumber), + ReferenceNumber::from($invoiceReferenceNumber) + )) ->body(); } if ($statusCode === 440 && !empty($originalKsefNumber) && !empty($originalSessionReferenceNumber)) { @@ -136,7 +145,6 @@ public function getInvoiceStatus( 'ksef_number' => $ksefNumber, 'permanent_storage_date' => $this->extractPermanentStorageDate($response), 'original_ksef_number' => $originalKsefNumber, - 'original_session_reference_number' => $originalSessionReferenceNumber, 'upo' => $upo, ]; } @@ -147,16 +155,21 @@ private function buildClient(KSeFConfig $config, ?string $sellerTen = null) throw new \RuntimeException('Missing n1ebieski/ksef-php-client dependency. Run composer install.'); } + $clientKey = spl_object_hash($config) . ':' . $sellerTen; + if (isset($this->clients[$clientKey])) { + return $this->clients[$clientKey]; + } + $builder = (new \N1ebieski\KSEFClient\ClientBuilder()) ->withMode($this->mode($config)) ->withEncryptionKey(\N1ebieski\KSEFClient\Factories\EncryptionKeyFactory::makeRandom()) - ->withValidateXml(true); + ->withValidateXml(false); if ($sellerTen !== null && $sellerTen !== '') { $builder = $builder->withIdentifier($sellerTen); } - if ($config->getAuthMethod() === KSeFConfig::AUTH_METHOD_TOKEN) { + if ($config->usesApiToken()) { $builder = $builder->withKsefToken($config->getToken()); } else { $builder = $builder->withCertificatePath( @@ -165,7 +178,9 @@ private function buildClient(KSeFConfig $config, ?string $sellerTen = null) ); } - return $builder->build(); + $this->clients[$clientKey] = $builder->build(); + + return $this->clients[$clientKey]; } private function mode(KSeFConfig $config) @@ -240,19 +255,15 @@ private function formatXmlValidationException(\Throwable $exception): string private function createInvoiceListRequest( string $sessionReferenceNumber, - int $pageSize, ?string $continuationToken = null - ): array { - $request = [ - 'referenceNumber' => $sessionReferenceNumber, - 'pageSize' => $pageSize, - ]; - - if ($continuationToken !== null) { - $request['continuationToken'] = $continuationToken; - } - - return $request; + ): ListRequest { + return new ListRequest( + ReferenceNumber::from($sessionReferenceNumber), + PageSize::from(self::INVOICE_REFERENCE_PAGE_SIZE), + $continuationToken === null + ? new Optional() + : ContinuationToken::from($continuationToken) + ); } private function readStringProperty($object, string $property): string diff --git a/modules/invoiceksefinfo.php b/modules/invoiceksefinfo.php index 2031d53f2c..bc1be64bcc 100644 --- a/modules/invoiceksefinfo.php +++ b/modules/invoiceksefinfo.php @@ -111,7 +111,7 @@ function invoiceKSeFRenderSendResult(array $result) $statusClass = 'red'; } if (empty($statusMessages)) { - if ((int) $document['status'] === KSeFSubmissionService::STATUS_ACCEPTED) { + if ((int) $document['status'] === KSeF::STATUS_ACCEPTED) { $statusMessages[] = $document['statusdescription'] ?: trans('KSeF accepted'); } elseif (isset($document['status'])) { $statusMessages[] = ($document['statusdescription'] ?: trans('waiting for KSeF handling')) @@ -134,7 +134,7 @@ function invoiceKSeFRenderSendResult(array $result) . '' . trans('View UPO') . ''; - } elseif ((int) $document['status'] === KSeFSubmissionService::STATUS_ACCEPTED) { + } elseif ((int) $document['status'] === KSeF::STATUS_ACCEPTED) { $upo = trans('UPO not available'); } @@ -221,16 +221,15 @@ function invoiceKSeFRenderSendResult(array $result) 'backurl' => $backUrl, ]; try { - $section = 'ksef'; $repository = new KSeFRepository($DB); - $configProvider = function (?int $divisionId = null) use ($section) { + $configProvider = function (?int $divisionId = null) { if ($divisionId !== null) { ConfigHelper::setFilter($divisionId); } - return KSeFConfig::fromConfigHelper($section, true); + return KSeFConfig::fromConfigHelper(true); }; - $config = KSeFConfig::fromConfigHelper($section, false); + $config = KSeFConfig::fromConfigHelper(false); $ksef = new KSeF($DB, $LMS); $service = new KSeFSubmissionService( $repository, diff --git a/tests/lib/KSeF/FakeKSeFRepository.php b/tests/lib/KSeF/FakeKSeFRepository.php index ca56ffb273..e3098db4a5 100644 --- a/tests/lib/KSeF/FakeKSeFRepository.php +++ b/tests/lib/KSeF/FakeKSeFRepository.php @@ -21,6 +21,8 @@ class FakeKSeFRepository implements KSeFRepositoryInterface public $pendingDivisionId = null; public $pendingCustomerId = null; public $pendingDocIds = null; + public $eligibleQueryCount = 0; + public $pendingQueryCount = 0; private $eligibleInvoices; private $pendingDocuments; @@ -37,6 +39,7 @@ public function getEligibleInvoices( ?int $customerId = null, ?array $docIds = null ): array { + $this->eligibleQueryCount++; $this->eligibleDocIds = $docIds; $eligibleInvoices = $this->eligibleInvoices; if ($docIds !== null) { @@ -129,6 +132,7 @@ public function getPendingDocuments( ?int $customerId = null, ?array $docIds = null ): array { + $this->pendingQueryCount++; $this->pendingDivisionId = $divisionId; $this->pendingCustomerId = $customerId; $this->pendingDocIds = $docIds; diff --git a/tests/lib/KSeF/KSeFConfigTest.php b/tests/lib/KSeF/KSeFConfigTest.php index 515a2486fd..64f831f524 100644 --- a/tests/lib/KSeF/KSeFConfigTest.php +++ b/tests/lib/KSeF/KSeFConfigTest.php @@ -20,14 +20,12 @@ public function testBuildsTestEnvironmentTokenConfigFromArray() { $config = KSeFConfig::fromArray([ 'environment' => '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->assertTrue($config->usesApiToken()); $this->assertSame('secret-token', $config->getToken()); $this->assertSame(25, $config->getMaxDocuments()); } @@ -36,14 +34,12 @@ 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->assertFalse($config->usesApiToken()); $this->assertSame('/secure/ksef.p12', $config->getCertificatePath()); $this->assertSame('cert-password', $config->getCertificatePassword()); $this->assertSame(10000, $config->getMaxDocuments()); @@ -56,10 +52,24 @@ public function testInfersTokenAuthWhenTokenIsConfigured() 'token' => 'secret-token', ]); - $this->assertSame('token', $config->getAuthMethod()); + $this->assertTrue($config->usesApiToken()); $this->assertSame('secret-token', $config->getToken()); } + public function testRecognizesStandardLmsCertificateSettingWhenItContainsApiToken() + { + $token = str_repeat('a', 64); + + $config = KSeFConfig::fromArray([ + 'environment' => 'test', + 'certificate_path' => $token, + ]); + + $this->assertTrue($config->usesApiToken()); + $this->assertSame($token, $config->getToken()); + $this->assertSame(null, $config->getCertificatePath()); + } + public function testRejectsUnknownEnvironment() { $this->expectException(\InvalidArgumentException::class); @@ -67,19 +77,17 @@ public function testRejectsUnknownEnvironment() KSeFConfig::fromArray([ 'environment' => 'sandbox', - 'auth_method' => 'token', 'token' => 'secret-token', ]); } - public function testRejectsTokenAuthWithoutToken() + public function testRejectsMissingCredentials() { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('KSeF token is required'); + $this->expectExceptionMessage('KSeF certificate or API token is required'); KSeFConfig::fromArray([ 'environment' => 'test', - 'auth_method' => 'token', ]); } @@ -87,39 +95,23 @@ 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->assertFalse($config->usesApiToken()); $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() + public function testBoundsBatchLimitToKSeFApiLimit() { $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 index f0a517f0ec..49d5ae6ef9 100644 --- a/tests/lib/KSeF/KSeFSubmissionServiceTest.php +++ b/tests/lib/KSeF/KSeFSubmissionServiceTest.php @@ -99,7 +99,6 @@ function (?int $divisionId) { ); $selectionConfig = KSeFConfig::fromArray([ 'environment' => 'test', - 'auth_method' => 'certificate', ], false); $result = $service->send($selectionConfig); @@ -144,6 +143,22 @@ public function testSendSelectedInvoicesIgnoresConfiguredMaxDocuments() ], $gateway->sentXmlBatches[0]); } + public function testSendDoesNothingWhenSelectedInvoiceListIsEmpty() + { + $repository = new FakeKSeFRepository([ + $this->invoice(123), + ]); + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->send($this->ksefConfig(), null, null, []); + + $this->assertSame(0, $result['submitted']); + $this->assertSame(0, $result['skipped']); + $this->assertSame(0, $repository->eligibleQueryCount); + $this->assertSame([], $gateway->sentXmlBatches); + } + public function testSendSkipsInvoiceWhenXmlBuilderReturnsError() { $repository = new FakeKSeFRepository([ @@ -224,7 +239,7 @@ public function testSendReportsReservationSkipReasonWhenNoDocumentsWereReserved( $this->assertSame([], $gateway->sentXmlBatches); } - public function testSendRemovesLocalReservationWhenCloseFailsAfterXmlWasSent() + public function testSendKeepsRemoteSessionReferenceWhenCloseFailsAfterXmlWasSent() { $repository = new FakeKSeFRepository([ $this->invoice(123), @@ -239,7 +254,8 @@ public function testSendRemovesLocalReservationWhenCloseFailsAfterXmlWasSent() $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('SESSION-1', $repository->sessionReferenceUpdates[0]['reference_number']); + $this->assertSame([], $repository->discardedSessions); $this->assertSame([], $repository->statusUpdates); } @@ -552,6 +568,30 @@ function (int $seconds) use (&$sleeps) { $this->assertSame(0, $repository->statusUpdates[0]['status']); } + public function testSyncUsesOnlyOneWaitWindowWhenExpectedOrdinalNeverAppears() + { + $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', + ], + ]; + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(0, $result['updated']); + $this->assertSame(1, count($result['errors'])); + $this->assertSame($this->expectedInvoiceReferenceLookupCount(), count($gateway->listedSessions)); + } + public function testSyncWaitsForMissingInvoiceReferencesOnlyOncePerSession() { $repository = new FakeKSeFRepository([], [ @@ -666,6 +706,78 @@ public function testSyncSelectedInvoicesIgnoresConfiguredMaxDocuments() $this->assertSame(11, $repository->statusUpdates[1]['id']); } + public function testSyncDoesNothingWhenSelectedInvoiceListIsEmpty() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument(), + ]); + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig(), null, null, []); + + $this->assertSame(0, $result['updated']); + $this->assertSame([], $result['errors']); + $this->assertSame(0, $repository->pendingQueryCount); + $this->assertSame([], $gateway->listedSessions); + } + + public function testSyncLoadsDivisionConfigOnlyOncePerRun() + { + $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(); + $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' => 0]; + $gateway->invoiceStatuses['SESSION-1:INVOICE-2'] = ['status' => 0]; + $configCalls = 0; + $service = $this->service( + $repository, + $gateway, + null, + function () use (&$configCalls) { + $configCalls++; + + return $this->ksefConfig(); + } + ); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(2, $result['updated']); + $this->assertSame(1, $configCalls); + } + + public function testSyncRejectsDocumentWithoutSellerTenBeforeCallingKSeF() + { + $repository = new FakeKSeFRepository([], [ + $this->pendingDocument(['seller_ten' => '']), + ]); + $gateway = new FakeKSeFGateway(); + $service = $this->service($repository, $gateway); + + $result = $service->sync($this->ksefConfig()); + + $this->assertSame(0, $result['updated']); + $this->assertSame('Missing seller TEN.', $result['errors'][0]['error']); + $this->assertSame([], $gateway->listedSessions); + } + public function testSyncKeepsDocumentPendingWhenUpoCannotBeSaved() { $repository = new FakeKSeFRepository([], [ @@ -711,7 +823,6 @@ public function testSyncTreatsDuplicateInvoiceStatusWithOriginalKsefNumberAsAcce '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); @@ -741,7 +852,6 @@ public function testSyncSavesOriginalUpoForDuplicateInvoiceWhenKsefReturnsIt() '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); @@ -759,7 +869,6 @@ private function ksefConfig(string $environment = 'test', string $token = 'secre { return KSeFConfig::fromArray([ 'environment' => $environment, - 'auth_method' => 'token', 'token' => $token, 'max_documents' => $maxDocuments, ]); diff --git a/tests/lib/KSeF/KSeFTest.php b/tests/lib/KSeF/KSeFTest.php index ffdd99bee9..9993ca8b5b 100644 --- a/tests/lib/KSeF/KSeFTest.php +++ b/tests/lib/KSeF/KSeFTest.php @@ -95,6 +95,31 @@ public function testGetInvoiceXmlAcceptsLmsDoctypeKeyWithoutWarning() $this->assertStringContainsString('KSeF Test Company', $xml); } + public function testGetInvoiceXmlRestoresNumericLocaleWhenGenerationFails() + { + $originalLocale = setlocale(LC_NUMERIC, '0'); + $testLocale = setlocale(LC_NUMERIC, 'C.utf8'); + if ($testLocale === false || $testLocale === 'C') { + $this->markTestSkipped('A distinct C UTF-8 locale is not available.'); + } + + $ksef = $this->ksefXmlGenerator(); + set_error_handler(function ($severity, $message) { + throw new \ErrorException($message, 0, $severity); + }); + try { + try { + $ksef->getInvoiceXml([]); + $this->fail('Invoice generation should fail for an empty invoice.'); + } catch (\Throwable $e) { + $this->assertSame($testLocale, setlocale(LC_NUMERIC, '0')); + } + } finally { + restore_error_handler(); + setlocale(LC_NUMERIC, $originalLocale); + } + } + public function testSaveUpoContentCreatesMissingStorageDirectory() { $storageDir = STORAGE_DIR . DIRECTORY_SEPARATOR . 'ksef'; diff --git a/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php index 83f40fa46d..11dc101d7f 100644 --- a/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php +++ b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php @@ -16,6 +16,8 @@ class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); use N1ebieski\KSEFClient\Requests\Sessions\Batch\Close\CloseRequest; use N1ebieski\KSEFClient\Requests\Sessions\Batch\OpenAndSend\OpenAndSendXmlRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\KsefUpo\KsefUpoRequest; + use N1ebieski\KSEFClient\Requests\Sessions\Invoices\List\ListRequest; + use N1ebieski\KSEFClient\ValueObjects\Requests\ContinuationToken; use N1ebieski\KSEFClient\ValueObjects\Requests\Sessions\FormCode; use PHPUnit\Framework\TestCase; @@ -110,13 +112,14 @@ public function testCreatesPaginatedInvoiceListRequest() $method = new \ReflectionMethod($gateway, 'createInvoiceListRequest'); $method->setAccessible(true); - $request = $method->invoke($gateway, 'SESSION-1', 500, 'NEXT-PAGE'); + $sessionReferenceNumber = '20260424-SO-ABCDEFGHIJ-1234567890-AB'; + $request = $method->invoke($gateway, $sessionReferenceNumber, 'NEXT-PAGE'); - $this->assertSame([ - 'referenceNumber' => 'SESSION-1', - 'pageSize' => 500, - 'continuationToken' => 'NEXT-PAGE', - ], $request); + $this->assertInstanceOf(ListRequest::class, $request); + $this->assertSame($sessionReferenceNumber, $request->referenceNumber->value); + $this->assertSame(1000, $request->pageSize->value); + $this->assertInstanceOf(ContinuationToken::class, $request->continuationToken); + $this->assertSame('NEXT-PAGE', $request->continuationToken->value); } public function testFormatsXmlValidationErrorsWithLineAndColumn() From c8f64c8d17441d4b53f8ea7300240d37039c9c81 Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Jul 2026 13:46:56 +0200 Subject: [PATCH 12/17] refactor: separate KSeF result presentation --- lib/KSeF/KSeFConfig.php | 1 - modules/invoiceksefinfo.php | 162 ++++++------------ .../invoice/invoiceksefsendresult.html | 60 +++++++ 3 files changed, 111 insertions(+), 112 deletions(-) create mode 100644 templates/default/invoice/invoiceksefsendresult.html diff --git a/lib/KSeF/KSeFConfig.php b/lib/KSeF/KSeFConfig.php index 1c4e5560fb..6cdde30381 100644 --- a/lib/KSeF/KSeFConfig.php +++ b/lib/KSeF/KSeFConfig.php @@ -124,5 +124,4 @@ private static function nullableString($value): ?string return $value === '' ? null : $value; } - } diff --git a/modules/invoiceksefinfo.php b/modules/invoiceksefinfo.php index bc1be64bcc..12e03cfbfa 100644 --- a/modules/invoiceksefinfo.php +++ b/modules/invoiceksefinfo.php @@ -30,36 +30,12 @@ use \Lms\KSeF\KSeFSubmissionService; use \Lms\KSeF\N1ebieskiKSeFGateway; -function invoiceKSeFResultKey() +function prepareInvoiceKSeFSendResult(array $result) { - 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 ''; + $result['backurl'] = $result['backurl'] ?? '?m=invoicelist'; if (!empty($result['error'])) { - echo '

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

'; - echo '

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

'; - return; + return $result; } $sendResult = $result['send_result']; @@ -67,12 +43,9 @@ function invoiceKSeFRenderSendResult(array $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 - . '

'; + $result['submitted'] = intval($sendResult['submitted']); + $result['updated'] = intval($syncResult['updated']); + $result['skipped'] = intval($sendResult['skipped']) + count($skippedDocIds); $sendErrors = []; foreach ($sendResult['errors'] as $error) { @@ -84,81 +57,47 @@ function invoiceKSeFRenderSendResult(array $result) } $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'] === KSeF::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'); - } - } + foreach ($resultDocuments as &$document) { + $docId = (int) $document['id']; + $ksefDocumentId = (int) $document['ksefdocumentid']; + $statusMessages = []; - $upo = '-'; - if (!empty($document['ksefnumber']) && KSeF::upoFileExists($document['ksefnumber'])) { - $upo = '' - . trans('Download UPO') - . '' - . ' | ' - . '' - . trans('View UPO') - . ''; - } elseif ((int) $document['status'] === KSeF::STATUS_ACCEPTED) { - $upo = trans('UPO not available'); + if (isset($skippedMap[$docId])) { + $statusMessages[] = trans('Document is not eligible for KSeF submission or has been submitted already.'); + } + if (!empty($sendErrors[$docId])) { + $statusMessages = array_merge($statusMessages, $sendErrors[$docId]); + } + if ($ksefDocumentId && !empty($syncErrors[$ksefDocumentId])) { + $statusMessages = array_merge($statusMessages, $syncErrors[$ksefDocumentId]); + } + $document['has_errors'] = !empty($statusMessages); + + if (empty($statusMessages)) { + if ((int) $document['status'] === KSeF::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'); } - - 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 . '
'; + + $document['status_message'] = implode(' ', $statusMessages); + $document['accepted'] = (int) $document['status'] === KSeF::STATUS_ACCEPTED; + $document['has_upo'] = !empty($document['ksefnumber']) + && KSeF::upoFileExists($document['ksefnumber']); } + unset($document); + + $result['result_documents'] = $resultDocuments; - echo '

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

'; + return $result; } if (!empty($_GET['action']) && $_GET['action'] == 'send-result') { @@ -167,23 +106,24 @@ function invoiceKSeFRenderSendResult(array $result) } $resultKey = preg_replace('/[^a-f0-9]/', '', $_GET['key'] ?? ''); - $result = null; + $result = []; 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([ + $result = [ 'backurl' => '?m=invoicelist', 'error' => trans('KSeF submission result is not available.'), - ]); - } else { - invoiceKSeFRenderSendResult($result); + ]; } + + $layout['pagetitle'] = trans('KSeF invoice handling'); + $SMARTY->assign('result', prepareInvoiceKSeFSendResult($result)); + $SMARTY->display('header.html'); + $SMARTY->display('invoice/invoiceksefsendresult.html'); $SMARTY->display('footer.html'); die; } @@ -291,7 +231,7 @@ function (array $invoice) use ($LMS, $ksef) { $result['error'] = $e->getMessage(); } - $resultKey = invoiceKSeFResultKey(); + $resultKey = bin2hex(random_bytes(8)); $SESSION->save('invoiceksefresult.' . $resultKey, $result); $SESSION->redirect('?m=invoiceksefinfo&action=send-result&key=' . $resultKey); } diff --git a/templates/default/invoice/invoiceksefsendresult.html b/templates/default/invoice/invoiceksefsendresult.html new file mode 100644 index 0000000000..b4b40d90ae --- /dev/null +++ b/templates/default/invoice/invoiceksefsendresult.html @@ -0,0 +1,60 @@ +

{trans("KSeF invoice handling")}

+ + + +{if !empty($result.error)} +

{$result.error|escape}

+{else} +

+ {trans("KSeF submitted:")} {$result.submitted}, + {trans("KSeF synchronized:")} {$result.updated}, + {trans("skipped:")} {$result.skipped} +

+ + {if !empty($result.result_documents)} + + + + + + + + + + + {foreach $result.result_documents as $document} + + + + + + + {/foreach} + +
{trans("Document")}{trans("Status")}{trans("KSeF number")}{trans("UPO")}
{$document.fullnumber|escape}{$document.status_message|escape}{if !empty($document.ksefnumber)}{$document.ksefnumber|escape}{else}-{/if} + {if $document.has_upo} + {trans("Download UPO")} + | + {trans("View UPO")} + {elseif $document.accepted} + {trans("UPO not available")} + {else} + - + {/if} +
+ {/if} +{/if} + +

+ {trans("Return to invoice list")} +

From 4468627a81edeb2035704a43c6c3408eccfd6470 Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Jul 2026 14:16:11 +0200 Subject: [PATCH 13/17] refactor: reduce KSeF submission plumbing --- lib/KSeF/KSeFSubmissionService.php | 41 ++---- lib/KSeF/N1ebieskiKSeFGateway.php | 27 +--- lib/locale/pl_PL/strings.php | 6 - modules/invoiceksefinfo.php | 118 +----------------- .../invoice/invoiceksefsendresult.html | 61 +++------ tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php | 51 -------- 6 files changed, 38 insertions(+), 266 deletions(-) diff --git a/lib/KSeF/KSeFSubmissionService.php b/lib/KSeF/KSeFSubmissionService.php index a15c308c6a..7feb835224 100644 --- a/lib/KSeF/KSeFSubmissionService.php +++ b/lib/KSeF/KSeFSubmissionService.php @@ -129,11 +129,6 @@ public function send( time() ); - if (empty($reserved['documents'])) { - $this->addReservationSkippedErrors($result, $reserved, $preparedInvoices); - continue; - } - foreach ($reserved['skipped'] as $docId => $error) { $result['skipped']++; $result['errors'][] = [ @@ -142,6 +137,19 @@ public function send( ]; } + if (empty($reserved['documents'])) { + if (empty($reserved['skipped'])) { + foreach ($preparedInvoices as $preparedInvoice) { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => (int) $preparedInvoice['invoice']['id'], + 'error' => 'Invoice is already reserved for KSeF submission.', + ]; + } + } + continue; + } + $reservedDocIds = []; foreach ($reserved['documents'] as $document) { $reservedDocIds[(int) $document['docid']] = true; @@ -325,29 +333,6 @@ private function getDocumentLimit(KSeFConfig $config, ?array $docIds): int return count($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)); diff --git a/lib/KSeF/N1ebieskiKSeFGateway.php b/lib/KSeF/N1ebieskiKSeFGateway.php index 60f8a327a3..084629d2f8 100644 --- a/lib/KSeF/N1ebieskiKSeFGateway.php +++ b/lib/KSeF/N1ebieskiKSeFGateway.php @@ -39,7 +39,7 @@ public function sendXmlBatch(KSeFConfig $config, string $sellerTen, array $xmlDo $response = $this->buildClient($config, $sellerTen) ->sessions() ->batch() - ->openAndSend($this->createOpenAndSendXmlRequest($xmlDocuments)) + ->openAndSend(new OpenAndSendXmlRequest(FormCode::Fa3, $xmlDocuments)) ->object(); return $this->readStringProperty($response, 'referenceNumber'); @@ -50,7 +50,7 @@ public function closeBatchSession(KSeFConfig $config, string $sellerTen, string $this->buildClient($config, $sellerTen) ->sessions() ->batch() - ->close($this->createCloseRequest($sessionReferenceNumber)) + ->close(new CloseRequest(ReferenceNumber::from($sessionReferenceNumber))) ->status(); } @@ -195,31 +195,16 @@ private function mode(KSeFConfig $config) } } - 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)) + ->ksefUpo(new KsefUpoRequest( + ReferenceNumber::from($sessionReferenceNumber), + KsefNumber::from($ksefNumber) + )) ->body(); } catch (\Throwable $e) { return null; diff --git a/lib/locale/pl_PL/strings.php b/lib/locale/pl_PL/strings.php index b8e9d38f4a..6b9ddc7c8e 100644 --- a/lib/locale/pl_PL/strings.php +++ b/lib/locale/pl_PL/strings.php @@ -6423,12 +6423,6 @@ $_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 -'; diff --git a/modules/invoiceksefinfo.php b/modules/invoiceksefinfo.php index 12e03cfbfa..dfb94afd7d 100644 --- a/modules/invoiceksefinfo.php +++ b/modules/invoiceksefinfo.php @@ -30,76 +30,6 @@ use \Lms\KSeF\KSeFSubmissionService; use \Lms\KSeF\N1ebieskiKSeFGateway; -function prepareInvoiceKSeFSendResult(array $result) -{ - $result['backurl'] = $result['backurl'] ?? '?m=invoicelist'; - - if (!empty($result['error'])) { - return $result; - } - - $sendResult = $result['send_result']; - $syncResult = $result['sync_result']; - $skippedDocIds = $result['skipped_doc_ids']; - $resultDocuments = $result['result_documents']; - - $result['submitted'] = intval($sendResult['submitted']); - $result['updated'] = intval($syncResult['updated']); - $result['skipped'] = intval($sendResult['skipped']) + count($skippedDocIds); - - $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); - - foreach ($resultDocuments as &$document) { - $docId = (int) $document['id']; - $ksefDocumentId = (int) $document['ksefdocumentid']; - $statusMessages = []; - - if (isset($skippedMap[$docId])) { - $statusMessages[] = trans('Document is not eligible for KSeF submission or has been submitted already.'); - } - if (!empty($sendErrors[$docId])) { - $statusMessages = array_merge($statusMessages, $sendErrors[$docId]); - } - if ($ksefDocumentId && !empty($syncErrors[$ksefDocumentId])) { - $statusMessages = array_merge($statusMessages, $syncErrors[$ksefDocumentId]); - } - $document['has_errors'] = !empty($statusMessages); - - if (empty($statusMessages)) { - if ((int) $document['status'] === KSeF::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'); - } - } - - $document['status_message'] = implode(' ', $statusMessages); - $document['accepted'] = (int) $document['status'] === KSeF::STATUS_ACCEPTED; - $document['has_upo'] = !empty($document['ksefnumber']) - && KSeF::upoFileExists($document['ksefnumber']); - } - unset($document); - - $result['result_documents'] = $resultDocuments; - - return $result; -} - if (!empty($_GET['action']) && $_GET['action'] == 'send-result') { if (!ConfigHelper::checkPrivileges('finances_management', 'financial_operations')) { die('Access denied.'); @@ -115,13 +45,13 @@ function prepareInvoiceKSeFSendResult(array $result) if (empty($result) || !is_array($result)) { $result = [ - 'backurl' => '?m=invoicelist', 'error' => trans('KSeF submission result is not available.'), ]; } + $result['backurl'] = $result['backurl'] ?? '?m=invoicelist'; $layout['pagetitle'] = trans('KSeF invoice handling'); - $SMARTY->assign('result', prepareInvoiceKSeFSendResult($result)); + $SMARTY->assign('result', $result); $SMARTY->display('header.html'); $SMARTY->display('invoice/invoiceksefsendresult.html'); $SMARTY->display('footer.html'); @@ -185,48 +115,8 @@ function (array $invoice) use ($LMS, $ksef) { $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' - ) ?: []; + $result['send_result'] = $service->send($config, null, null, $docIds); + $result['sync_result'] = $service->sync($config, null, null, $docIds); } catch (\Throwable $e) { $result['error'] = $e->getMessage(); } diff --git a/templates/default/invoice/invoiceksefsendresult.html b/templates/default/invoice/invoiceksefsendresult.html index b4b40d90ae..7c4b5997b8 100644 --- a/templates/default/invoice/invoiceksefsendresult.html +++ b/templates/default/invoice/invoiceksefsendresult.html @@ -1,57 +1,26 @@

{trans("KSeF invoice handling")}

- - {if !empty($result.error)}

{$result.error|escape}

{else}

- {trans("KSeF submitted:")} {$result.submitted}, - {trans("KSeF synchronized:")} {$result.updated}, - {trans("skipped:")} {$result.skipped} + {trans("KSeF submitted:")} {$result.send_result.submitted}, + {trans("KSeF synchronized:")} {$result.sync_result.updated}

- {if !empty($result.result_documents)} - - - - - - - - - - - {foreach $result.result_documents as $document} - - - - - - - {/foreach} - -
{trans("Document")}{trans("Status")}{trans("KSeF number")}{trans("UPO")}
{$document.fullnumber|escape}{$document.status_message|escape}{if !empty($document.ksefnumber)}{$document.ksefnumber|escape}{else}-{/if} - {if $document.has_upo} - {trans("Download UPO")} - | - {trans("View UPO")} - {elseif $document.accepted} - {trans("UPO not available")} - {else} - - - {/if} -
+ {if !empty($result.send_result.errors)} +
    + {foreach $result.send_result.errors as $error} +
  • {trans("Document")} {$error.docid|escape}: {$error.error|escape}
  • + {/foreach} +
+ {/if} + {if !empty($result.sync_result.errors)} +
    + {foreach $result.sync_result.errors as $error} +
  • KSeF {$error.id|escape}: {$error.error|escape}
  • + {/foreach} +
{/if} {/if} diff --git a/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php index 11dc101d7f..e8d531c246 100644 --- a/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php +++ b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php @@ -13,64 +13,13 @@ class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); require_once __DIR__ . '/FakeXmlValidationException.php'; use Lms\KSeF\N1ebieskiKSeFGateway; - use N1ebieski\KSEFClient\Requests\Sessions\Batch\Close\CloseRequest; - use N1ebieski\KSEFClient\Requests\Sessions\Batch\OpenAndSend\OpenAndSendXmlRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\KsefUpo\KsefUpoRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\List\ListRequest; use N1ebieski\KSEFClient\ValueObjects\Requests\ContinuationToken; - use N1ebieski\KSEFClient\ValueObjects\Requests\Sessions\FormCode; use PHPUnit\Framework\TestCase; class N1ebieskiKSeFGatewayTest extends TestCase { - public function testCreatesBatchXmlRequestForFa3Documents() - { - $gateway = new N1ebieskiKSeFGateway(); - $method = new \ReflectionMethod($gateway, 'createOpenAndSendXmlRequest'); - $method->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(); From 1d351cd52ad46d55d369041fc5bf9feec4942786 Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Jul 2026 14:52:13 +0200 Subject: [PATCH 14/17] refactor: simplify KSeF session synchronization --- lib/KSeF/KSeFGatewayInterface.php | 9 +- lib/KSeF/KSeFRepository.php | 11 - lib/KSeF/KSeFSubmissionService.php | 227 +++-- lib/KSeF/N1ebieskiKSeFGateway.php | 96 +- tests/lib/KSeF/ConfigHelper.php | 11 - tests/lib/KSeF/FakeKSeFGateway.php | 34 +- tests/lib/KSeF/FakeKSeFLms.php | 32 - tests/lib/KSeF/FakeKSeFRepository.php | 15 - tests/lib/KSeF/FakeXmlValidationException.php | 14 - tests/lib/KSeF/KSeFSubmissionServiceTest.php | 823 ++++-------------- tests/lib/KSeF/KSeFTest.php | 10 +- tests/lib/KSeF/LMS.php | 9 - tests/lib/KSeF/Localisation.php | 11 - tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php | 11 +- tests/lib/KSeF/Utils.php | 16 - 15 files changed, 312 insertions(+), 1017 deletions(-) delete mode 100644 tests/lib/KSeF/ConfigHelper.php delete mode 100644 tests/lib/KSeF/FakeKSeFLms.php delete mode 100644 tests/lib/KSeF/FakeXmlValidationException.php delete mode 100644 tests/lib/KSeF/LMS.php delete mode 100644 tests/lib/KSeF/Localisation.php delete mode 100644 tests/lib/KSeF/Utils.php diff --git a/lib/KSeF/KSeFGatewayInterface.php b/lib/KSeF/KSeFGatewayInterface.php index 79d9d24c9a..9a793b366f 100644 --- a/lib/KSeF/KSeFGatewayInterface.php +++ b/lib/KSeF/KSeFGatewayInterface.php @@ -10,12 +10,5 @@ public function sendXmlBatch(KSeFConfig $config, string $sellerTen, array $xmlDo public function closeBatchSession(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): void; - public function listInvoiceReferences(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): array; - - public function getInvoiceStatus( - KSeFConfig $config, - string $sellerTen, - string $sessionReferenceNumber, - string $invoiceReferenceNumber - ): array; + public function listInvoices(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): array; } diff --git a/lib/KSeF/KSeFRepository.php b/lib/KSeF/KSeFRepository.php index b3a4627b8c..8cc26b2d2a 100644 --- a/lib/KSeF/KSeFRepository.php +++ b/lib/KSeF/KSeFRepository.php @@ -147,15 +147,12 @@ public function reserveInvoices(array $documents, int $environment, int $created ); $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, ]; @@ -254,21 +251,13 @@ public function getPendingDocuments( '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), diff --git a/lib/KSeF/KSeFSubmissionService.php b/lib/KSeF/KSeFSubmissionService.php index 7feb835224..520317abf8 100644 --- a/lib/KSeF/KSeFSubmissionService.php +++ b/lib/KSeF/KSeFSubmissionService.php @@ -4,8 +4,8 @@ class KSeFSubmissionService { - const INVOICE_REFERENCE_RETRY_SECONDS = [1, 2, 3, 5, 10]; - const INVOICE_REFERENCE_WAIT_SECONDS = 600; + const INVOICE_LIST_RETRY_SECONDS = [1, 2, 3, 5, 10]; + const INVOICE_LIST_WAIT_SECONDS = 600; private $repository; private $gateway; @@ -138,15 +138,6 @@ public function send( } if (empty($reserved['documents'])) { - if (empty($reserved['skipped'])) { - foreach ($preparedInvoices as $preparedInvoice) { - $result['skipped']++; - $result['errors'][] = [ - 'docid' => (int) $preparedInvoice['invoice']['id'], - 'error' => 'Invoice is already reserved for KSeF submission.', - ]; - } - } continue; } @@ -163,30 +154,14 @@ public function send( } $sessionReferenceNumber = null; - $closeError = null; try { $sessionReferenceNumber = $this->gateway->sendXmlBatch($groupConfig, $sellerTen, $xmlDocuments); $this->repository->updateSessionReference($reserved['session_id'], $sessionReferenceNumber); $sessionReferenceStored = true; } 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->gateway->closeBatchSession($groupConfig, $sellerTen, $sessionReferenceNumber); } - continue; } $this->repository->closeSession($reserved['session_id']); @@ -239,7 +214,7 @@ public function sync( $customerId, $docIds ); - $invoiceReferenceCache = []; + $sessionGroups = []; foreach ($documents as $document) { try { $sellerTen = preg_replace('/[^0-9]/', '', $document['seller_ten'] ?? ''); @@ -250,49 +225,16 @@ public function sync( 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'] ?? KSeF::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 = KSeF::STATUS_ACCEPTED; - $ksefNumber = $status['original_ksef_number']; - } - - if ($statusCode === KSeF::STATUS_ACCEPTED - && !empty($ksefNumber) - && isset($status['upo']) - && is_string($status['upo']) - && $status['upo'] !== '' - ) { - $this->repository->saveUpo($ksefNumber, $status['upo']); + $groupKey = $sellerTen . ':' . $document['session_reference_number']; + if (!isset($sessionGroups[$groupKey])) { + $sessionGroups[$groupKey] = [ + 'config' => $documentConfig, + 'seller_ten' => $sellerTen, + 'reference_number' => $document['session_reference_number'], + 'documents' => [], + ]; } - - $this->repository->updateDocumentStatus( - (int) $document['id'], - $statusCode, - $statusDescription, - $statusDetails, - $ksefNumber, - $permanentStorageDate - ); - - $result['updated']++; + $sessionGroups[$groupKey]['documents'][] = $document; } catch (\Throwable $e) { $result['errors'][] = [ 'id' => (int) $document['id'], @@ -301,6 +243,45 @@ public function sync( } } + foreach ($sessionGroups as $sessionGroup) { + try { + $invoices = $this->waitForInvoices( + $sessionGroup['config'], + $sessionGroup['seller_ten'], + $sessionGroup['reference_number'], + $sessionGroup['documents'] + ); + } catch (\Throwable $e) { + foreach ($sessionGroup['documents'] as $document) { + $result['errors'][] = [ + 'id' => (int) $document['id'], + 'error' => $e->getMessage(), + ]; + } + continue; + } + + foreach ($sessionGroup['documents'] as $document) { + try { + $status = $this->findInvoice($invoices, $document); + if ($status === null) { + throw new \RuntimeException( + 'Couldn\'t find KSeF invoice for session ' . $document['session_reference_number'] + . ' and ordinal number ' . $document['ordinalnumber'] . '.' + ); + } + + $this->updateDocument($document, $status); + $result['updated']++; + } catch (\Throwable $e) { + $result['errors'][] = [ + 'id' => (int) $document['id'], + 'error' => $e->getMessage(), + ]; + } + } + } + return $result; } @@ -338,85 +319,87 @@ 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]; - - $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( + private function waitForInvoices( KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber, - array $document + array $documents ): array { $waitedSeconds = 0; - $lastInvoiceReferences = []; - for ($attempt = 0; $attempt === 0 || $waitedSeconds < self::INVOICE_REFERENCE_WAIT_SECONDS; $attempt++) { + $lastInvoices = []; + for ($attempt = 0; $attempt === 0 || $waitedSeconds < self::INVOICE_LIST_WAIT_SECONDS; $attempt++) { if ($attempt > 0) { - $sleepSeconds = self::INVOICE_REFERENCE_RETRY_SECONDS[ - min($attempt - 1, count(self::INVOICE_REFERENCE_RETRY_SECONDS) - 1) + $sleepSeconds = self::INVOICE_LIST_RETRY_SECONDS[ + min($attempt - 1, count(self::INVOICE_LIST_RETRY_SECONDS) - 1) ]; - $sleepSeconds = min($sleepSeconds, self::INVOICE_REFERENCE_WAIT_SECONDS - $waitedSeconds); + $sleepSeconds = min($sleepSeconds, self::INVOICE_LIST_WAIT_SECONDS - $waitedSeconds); call_user_func($this->sleeper, $sleepSeconds); $waitedSeconds += $sleepSeconds; } - $invoiceReferences = $this->gateway->listInvoiceReferences( + $invoices = $this->gateway->listInvoices( $config, $sellerTen, $sessionReferenceNumber ); - $lastInvoiceReferences = $invoiceReferences; - if ($this->findInvoiceReferenceNumber($invoiceReferences, $document) !== null) { - return $invoiceReferences; + $lastInvoices = $invoices; + if ($this->containsAllDocuments($invoices, $documents)) { + return $invoices; } } - return $lastInvoiceReferences; + return $lastInvoices; } - private function findInvoiceReferenceNumber(array $invoiceReferences, array $document): ?string + private function containsAllDocuments(array $invoices, array $documents): bool { - foreach ($invoiceReferences as $invoiceReference) { - if (isset($invoiceReference['ordinal_number']) - && (int) $invoiceReference['ordinal_number'] === (int) $document['ordinalnumber'] - && !empty($invoiceReference['reference_number']) + foreach ($documents as $document) { + if ($this->findInvoice($invoices, $document) === null) { + return false; + } + } + + return true; + } + + private function findInvoice(array $invoices, array $document): ?array + { + foreach ($invoices as $invoice) { + if (isset($invoice['ordinal_number']) + && (int) $invoice['ordinal_number'] === (int) $document['ordinalnumber'] ) { - return $invoiceReference['reference_number']; + return $invoice; } } - if ((int) ($document['session_document_count'] ?? 0) === 1 - && count($invoiceReferences) === 1 - && !empty($invoiceReferences[0]['reference_number']) + return null; + } + + private function updateDocument(array $document, array $status): void + { + $statusCode = (int) ($status['status'] ?? KSeF::STATUS_PENDING); + $ksefNumber = $status['ksef_number'] ?? null; + if ($statusCode === 440 && !empty($status['original_ksef_number'])) { + $statusCode = KSeF::STATUS_ACCEPTED; + $ksefNumber = $status['original_ksef_number']; + } + + if ($statusCode === KSeF::STATUS_ACCEPTED + && !empty($ksefNumber) + && !empty($status['upo']) + && is_string($status['upo']) ) { - return $invoiceReferences[0]['reference_number']; + $this->repository->saveUpo($ksefNumber, $status['upo']); } - return null; + $this->repository->updateDocumentStatus( + (int) $document['id'], + $statusCode, + $status['status_description'] ?? null, + $status['status_details'] ?? null, + $ksefNumber, + $this->normalizeStorageDate($status['permanent_storage_date'] ?? null) + ); } private function normalizeStorageDate(?string $date): ?string diff --git a/lib/KSeF/N1ebieskiKSeFGateway.php b/lib/KSeF/N1ebieskiKSeFGateway.php index 084629d2f8..b98f5c6ade 100644 --- a/lib/KSeF/N1ebieskiKSeFGateway.php +++ b/lib/KSeF/N1ebieskiKSeFGateway.php @@ -6,7 +6,6 @@ use N1ebieski\KSEFClient\Requests\Sessions\Batch\OpenAndSend\OpenAndSendXmlRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\KsefUpo\KsefUpoRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\List\ListRequest; -use N1ebieski\KSEFClient\Requests\Sessions\Invoices\Status\StatusRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\Upo\UpoRequest; use N1ebieski\KSEFClient\Support\Optional; use N1ebieski\KSEFClient\ValueObjects\Requests\ContinuationToken; @@ -17,7 +16,7 @@ class N1ebieskiKSeFGateway implements KSeFGatewayInterface { - const INVOICE_REFERENCE_PAGE_SIZE = 1000; + const INVOICE_LIST_PAGE_SIZE = 1000; private $clients = []; @@ -54,7 +53,7 @@ public function closeBatchSession(KSeFConfig $config, string $sellerTen, string ->status(); } - public function listInvoiceReferences(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): array + public function listInvoices(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): array { $client = $this->buildClient($config, $sellerTen); $invoices = []; @@ -77,9 +76,45 @@ public function listInvoiceReferences(KSeFConfig $config, string $sellerTen, str continue; } + $status = $invoice->status ?? null; + $statusCode = (int) ($status->code ?? 0); + $statusDetails = $this->extractStatusDetails($invoice); + $ksefNumber = $invoice->ksefNumber ?? null; + $originalKsefNumber = $status->extensions->originalKsefNumber + ?? $this->extractOriginalKsefNumberFromDetails($statusDetails); + $originalSessionReferenceNumber = $status->extensions->originalSessionReferenceNumber + ?? $this->extractOriginalSessionReferenceFromDetails($statusDetails); + $upo = null; + + if ($statusCode === KSeF::STATUS_ACCEPTED && !empty($ksefNumber)) { + $upo = $client + ->sessions() + ->invoices() + ->upo(new UpoRequest( + ReferenceNumber::from($sessionReferenceNumber), + ReferenceNumber::from($invoice->referenceNumber) + )) + ->body(); + } elseif ($statusCode === 440 + && !empty($originalKsefNumber) + && !empty($originalSessionReferenceNumber) + ) { + $upo = $this->fetchOriginalUpo( + $client, + $originalSessionReferenceNumber, + $originalKsefNumber + ); + } + $invoices[] = [ - 'reference_number' => $invoice->referenceNumber, 'ordinal_number' => isset($invoice->ordinalNumber) ? (int) $invoice->ordinalNumber : null, + 'status' => $statusCode, + 'status_description' => $status->description ?? null, + 'status_details' => $statusDetails, + 'ksef_number' => $ksefNumber, + 'permanent_storage_date' => $this->extractPermanentStorageDate($invoice), + 'original_ksef_number' => $originalKsefNumber, + 'upo' => $upo, ]; } } @@ -98,57 +133,6 @@ public function listInvoiceReferences(KSeFConfig $config, string $sellerTen, str return $invoices; } - public function getInvoiceStatus( - KSeFConfig $config, - string $sellerTen, - string $sessionReferenceNumber, - string $invoiceReferenceNumber - ): array { - $client = $this->buildClient($config, $sellerTen); - $response = $client - ->sessions() - ->invoices() - ->status(new StatusRequest( - ReferenceNumber::from($sessionReferenceNumber), - ReferenceNumber::from($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 === KSeF::STATUS_ACCEPTED && !empty($ksefNumber)) { - $upo = $client - ->sessions() - ->invoices() - ->upo(new UpoRequest( - ReferenceNumber::from($sessionReferenceNumber), - ReferenceNumber::from($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, - 'upo' => $upo, - ]; - } - private function buildClient(KSeFConfig $config, ?string $sellerTen = null) { if (!class_exists('\N1ebieski\KSEFClient\ClientBuilder')) { @@ -244,7 +228,7 @@ private function createInvoiceListRequest( ): ListRequest { return new ListRequest( ReferenceNumber::from($sessionReferenceNumber), - PageSize::from(self::INVOICE_REFERENCE_PAGE_SIZE), + PageSize::from(self::INVOICE_LIST_PAGE_SIZE), $continuationToken === null ? new Optional() : ContinuationToken::from($continuationToken) diff --git a/tests/lib/KSeF/ConfigHelper.php b/tests/lib/KSeF/ConfigHelper.php deleted file mode 100644 index 3db89085e5..0000000000 --- a/tests/lib/KSeF/ConfigHelper.php +++ /dev/null @@ -1,11 +0,0 @@ -closedBatchSessions[] = $sessionReferenceNumber; } - public function listInvoiceReferences(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): array + public function listInvoices(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]); + if (!empty($this->invoiceResponseSequences[$sessionReferenceNumber])) { + return array_shift($this->invoiceResponseSequences[$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]; + return $this->sessionInvoices[$sessionReferenceNumber] ?? []; } } diff --git a/tests/lib/KSeF/FakeKSeFLms.php b/tests/lib/KSeF/FakeKSeFLms.php deleted file mode 100644 index d97ae0d1e0..0000000000 --- a/tests/lib/KSeF/FakeKSeFLms.php +++ /dev/null @@ -1,32 +0,0 @@ - '', - '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 index e3098db4a5..2720b050aa 100644 --- a/tests/lib/KSeF/FakeKSeFRepository.php +++ b/tests/lib/KSeF/FakeKSeFRepository.php @@ -13,7 +13,6 @@ class FakeKSeFRepository implements KSeFRepositoryInterface public $discardedSessions = []; public $statusUpdates = []; public $savedUpos = []; - public $reservationFails = false; public $reservedSkipped = []; public $failUpoSave = false; public $failSessionReferenceUpdate = false; @@ -56,12 +55,6 @@ function (array $invoice) use ($docIds): bool { public function reserveInvoices(array $documents, int $environment, int $createdAt): array { - if ($this->reservationFails) { - return [ - 'skipped' => [], - 'documents' => [], - ]; - } if (!empty($this->reservedSkipped)) { return [ 'skipped' => $this->reservedSkipped, @@ -69,11 +62,8 @@ public function reserveInvoices(array $documents, int $environment, int $created ]; } - $sessionReferenceNumber = 'LOCAL-' . $documents[0]['docid']; $this->sessions[] = [ - 'reference_number' => $sessionReferenceNumber, 'environment' => $environment, - 'created_at' => $createdAt, ]; $sessionId = count($this->sessions); $reservedDocuments = []; @@ -84,19 +74,14 @@ public function reserveInvoices(array $documents, int $environment, int $created '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' => [], ]; diff --git a/tests/lib/KSeF/FakeXmlValidationException.php b/tests/lib/KSeF/FakeXmlValidationException.php deleted file mode 100644 index 05f8356d62..0000000000 --- a/tests/lib/KSeF/FakeXmlValidationException.php +++ /dev/null @@ -1,14 +0,0 @@ -context = $context; - } -} diff --git a/tests/lib/KSeF/KSeFSubmissionServiceTest.php b/tests/lib/KSeF/KSeFSubmissionServiceTest.php index 49d5ae6ef9..5d52d0c333 100644 --- a/tests/lib/KSeF/KSeFSubmissionServiceTest.php +++ b/tests/lib/KSeF/KSeFSubmissionServiceTest.php @@ -20,218 +20,104 @@ class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); class KSeFSubmissionServiceTest extends TestCase { - public function testSendSubmitsEligibleInvoicesInSingleBatchSessionAndClosesIt() + public function testSendReservesAndSubmitsOneBatchPerSellerAndDivision() { $repository = new FakeKSeFRepository([ - $this->invoice(123), - $this->invoice(124), + $this->invoice(123, '1234567890', 7), + $this->invoice(124, '1234567890', 7), + $this->invoice(125, '1234567890', 8), ]); $gateway = new FakeKSeFGateway(); - $service = $this->service($repository, $gateway); + $service = $this->service($repository, $gateway, null, function (?int $divisionId) { + return $this->config( + $divisionId === 8 ? 'production' : 'test', + 'division-' . $divisionId . '-token' + ); + }); - $result = $service->send($this->ksefConfig()); + $result = $service->send(KSeFConfig::fromArray(['environment' => 'test'], false)); - $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(['submitted' => 3, 'skipped' => 0, 'errors' => []], $result); + $this->assertCount(2, $repository->sessions); + $this->assertSame([KSeF::ENVIRONMENT_TEST, KSeF::ENVIRONMENT_PROD], array_column($repository->sessions, 'environment')); + $this->assertSame(['division-7-token', 'division-8-token'], array_column($gateway->sentConfigs, 'token')); $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'); - } + $this->assertSame(['125'], $gateway->sentXmlBatches[1]); + $this->assertSame([1, 2, 1], array_column($repository->documents, 'ordinalnumber')); + $this->assertSame( + base64_encode(hash('sha256', '123', true)), + $repository->documents[0]['hash'] ); - $selectionConfig = KSeFConfig::fromArray([ - 'environment' => 'test', - ], 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]); + $this->assertSame(['SESSION-1', 'SESSION-2'], $gateway->closedBatchSessions); + $this->assertCount(2, $repository->sessionCloseUpdates); } - public function testSendSelectedInvoicesIgnoresConfiguredMaxDocuments() + public function testSendHonoursExplicitSelectionInsteadOfConfiguredLimit() { $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]); + $result = $this->service($repository, $gateway)->send( + $this->config('test', 'token', 1), + null, + null, + [123, 124, 124] + ); $this->assertSame(2, $result['submitted']); $this->assertSame([123, 124], $repository->eligibleDocIds); - $this->assertSame([ - '123', - '124', - ], $gateway->sentXmlBatches[0]); + $this->assertCount(2, $gateway->sentXmlBatches[0]); } - public function testSendDoesNothingWhenSelectedInvoiceListIsEmpty() + public function testSendDoesNotQueryWhenSelectionIsEmpty() { - $repository = new FakeKSeFRepository([ - $this->invoice(123), - ]); + $repository = new FakeKSeFRepository([$this->invoice(123)]); $gateway = new FakeKSeFGateway(); - $service = $this->service($repository, $gateway); - $result = $service->send($this->ksefConfig(), null, null, []); + $result = $this->service($repository, $gateway)->send($this->config(), null, null, []); - $this->assertSame(0, $result['submitted']); - $this->assertSame(0, $result['skipped']); + $this->assertSame(['submitted' => 0, 'skipped' => 0, 'errors' => []], $result); $this->assertSame(0, $repository->eligibleQueryCount); $this->assertSame([], $gateway->sentXmlBatches); } - 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() + public function testSendSkipsOnlyInvoicesThatCannotProduceValidXml() { $repository = new FakeKSeFRepository([ $this->invoice(123), $this->invoice(124), + $this->invoice(125), ]); $gateway = new FakeKSeFGateway(); - $gateway->invalidXmlDocuments = [ - '124' => 'Invalid KSeF XML: NIP pattern mismatch.', - ]; - $service = $this->service($repository, $gateway); + $gateway->invalidXmlDocuments['124'] = 'Schema mismatch.'; + $service = $this->service($repository, $gateway, function (array $invoice) { + return $invoice['id'] === 125 + ? ['error' => 'Invalid buyer TEN.'] + : '' . $invoice['id'] . ''; + }); - $result = $service->send($this->ksefConfig()); + $result = $service->send($this->config()); $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); + $this->assertSame(2, $result['skipped']); + $this->assertSame([124, 125], array_column($result['errors'], 'docid')); + $this->assertSame(['Schema mismatch.', 'Invalid buyer TEN.'], array_column($result['errors'], 'error')); + $this->assertSame(['123'], $gateway->sentXmlBatches[0]); } - public function testSendReportsReservationSkipReasonWhenNoDocumentsWereReserved() + public function testSendReturnsRepositoryReservationReason() { - $repository = new FakeKSeFRepository([ - $this->invoice(123), - ]); - $repository->reservedSkipped = [ - 123 => 'Invoice disappeared during reservation.', - ]; + $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()); + $result = $this->service($repository, $gateway)->send($this->config()); $this->assertSame(0, $result['submitted']); $this->assertSame(1, $result['skipped']); @@ -239,539 +125,171 @@ public function testSendReportsReservationSkipReasonWhenNoDocumentsWereReserved( $this->assertSame([], $gateway->sentXmlBatches); } - public function testSendKeepsRemoteSessionReferenceWhenCloseFailsAfterXmlWasSent() + public function testSendKeepsRemoteReferenceWhenClosingSessionFails() { - $repository = new FakeKSeFRepository([ - $this->invoice(123), - ]); + $repository = new FakeKSeFRepository([$this->invoice(123)]); $gateway = new FakeKSeFGateway(); $gateway->failClose = true; - $service = $this->service($repository, $gateway); - $result = $service->send($this->ksefConfig()); + $result = $this->service($repository, $gateway)->send($this->config()); - $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('SESSION-1', $repository->sessionReferenceUpdates[0]['reference_number']); $this->assertSame([], $repository->discardedSessions); - $this->assertSame([], $repository->statusUpdates); + $this->assertSame([], $repository->sessionCloseUpdates); } - public function testSendClosesBatchSessionWhenLocalSessionReferenceUpdateFails() + public function testSendClosesRemoteSessionWhenSavingItsReferenceFails() { - $repository = new FakeKSeFRepository([ - $this->invoice(123), - ]); + $repository = new FakeKSeFRepository([$this->invoice(123)]); $repository->failSessionReferenceUpdate = true; $gateway = new FakeKSeFGateway(); - $service = $this->service($repository, $gateway); - $result = $service->send($this->ksefConfig()); + $result = $this->service($repository, $gateway)->send($this->config()); - $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() + public function testSyncMatchesSessionInvoicesByOrdinalAndUpdatesThemIndependently() { $repository = new FakeKSeFRepository([], [ - $this->pendingDocument([ - 'session_status' => 0, - ]), + $this->pendingDocument(['id' => 10, 'ordinalnumber' => 1]), + $this->pendingDocument(['id' => 11, 'ordinalnumber' => 2]), ]); $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, + $gateway->sessionInvoices['SESSION-1'] = [ + $this->remoteInvoice(1, [ + 'status' => 200, + 'status_description' => 'Accepted', + 'ksef_number' => '1234567890-20260424-ABCDEF', + 'permanent_storage_date' => '2026-04-24T10:00:00+02:00', + 'upo' => '', ]), - $this->pendingDocument([ - 'id' => 11, - 'ordinalnumber' => 2, - 'session_document_count' => 2, + $this->remoteInvoice(2, [ + 'status' => 450, + 'status_description' => 'Rejected', + 'status_details' => 'Invalid invoice.', ]), - ]); - $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()); + $result = $this->service($repository, $gateway)->sync($this->config()); - $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(['updated' => 2, 'errors' => []], $result); + $this->assertSame([10, 11], array_column($repository->statusUpdates, 'id')); + $this->assertSame([200, 450], array_column($repository->statusUpdates, 'status')); + $this->assertSame('2026-04-24 10:00:00', $repository->statusUpdates[0]['permanent_storage_date']); + $this->assertNull($repository->statusUpdates[1]['ksef_number']); $this->assertSame('', $repository->savedUpos[0]['content']); $this->assertSame(['SESSION-1'], $gateway->listedSessions); } - public function testSyncUsesDivisionScopedConfigForPendingDocument() + public function testSyncUsesDivisionConfigAndSelectionFilters() { $repository = new FakeKSeFRepository([], [ - $this->pendingDocument([ - 'divisionid' => 8, - ]), + $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'); - } - ); + $gateway->sessionInvoices['SESSION-1'] = [$this->remoteInvoice(1)]; + $configCalls = 0; + $service = $this->service($repository, $gateway, null, function (?int $divisionId) use (&$configCalls) { + $configCalls++; + return $this->config('production', 'division-' . $divisionId . '-token'); + }); - $result = $service->sync($this->ksefConfig()); + $result = $service->sync($this->config(), 8, 123); $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); + $this->assertSame(1, $configCalls); + $this->assertSame('division-8-token', $gateway->listedConfigs[0]['token']); } - public function testSyncWaitsForInvoiceReferencesWhenTheyAreNotReadyYet() + public function testSyncWaitsUntilAllSelectedSessionInvoicesAreVisible() { $repository = new FakeKSeFRepository([], [ - $this->pendingDocument(), + $this->pendingDocument(['id' => 10, 'ordinalnumber' => 1]), + $this->pendingDocument(['id' => 11, 'ordinalnumber' => 2]), ]); $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' => '', + $gateway->invoiceResponseSequences['SESSION-1'] = [ + [], + [$this->remoteInvoice(1)], + [$this->remoteInvoice(1), $this->remoteInvoice(2)], ]; $sleeps = []; - $service = $this->service( - $repository, - $gateway, - null, - null, - function (int $seconds) use (&$sleeps) { - $sleeps[] = $seconds; - } - ); + $service = $this->service($repository, $gateway, null, null, function (int $seconds) use (&$sleeps) { + $sleeps[] = $seconds; + }); - $result = $service->sync($this->ksefConfig()); + $result = $service->sync($this->config()); - $this->assertSame(1, $result['updated']); + $this->assertSame(2, $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); + $this->assertSame([1, 2], $sleeps); } - public function testSyncWaitsForExpectedOrdinalWhenInvoiceReferencesArePartial() + public function testSyncUsesOneWaitWindowForAllMissingDocumentsInSession() { $repository = new FakeKSeFRepository([], [ - $this->pendingDocument([ - 'ordinalnumber' => 2, - 'session_document_count' => 2, - ]), + $this->pendingDocument(['id' => 10, 'ordinalnumber' => 1]), + $this->pendingDocument(['id' => 11, 'ordinalnumber' => 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()); + $result = $this->service($repository, $gateway)->sync($this->config()); - $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']); + $this->assertSame(0, $result['updated']); + $this->assertCount(2, $result['errors']); + $this->assertCount($this->expectedInvoiceLookupCount(), $gateway->listedSessions); } - public function testSyncUsesOnlyOneWaitWindowWhenExpectedOrdinalNeverAppears() + public function testSyncHonoursExplicitSelectionInsteadOfConfiguredLimit() { $repository = new FakeKSeFRepository([], [ - $this->pendingDocument([ - 'ordinalnumber' => 2, - 'session_document_count' => 2, - ]), + $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'] = [ - [ - 'ordinal_number' => 1, - 'reference_number' => 'INVOICE-1', - ], + $gateway->sessionInvoices = [ + 'SESSION-1' => [$this->remoteInvoice(1)], + 'SESSION-2' => [$this->remoteInvoice(1)], ]; - $service = $this->service($repository, $gateway); - - $result = $service->sync($this->ksefConfig()); - $this->assertSame(0, $result['updated']); - $this->assertSame(1, count($result['errors'])); - $this->assertSame($this->expectedInvoiceReferenceLookupCount(), count($gateway->listedSessions)); - } - - 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, + $result = $this->service($repository, $gateway)->sync( + $this->config('test', 'token', 1), null, null, - function (int $seconds) use (&$sleeps) { - $sleeps[] = $seconds; - } + [123, 124, 124] ); - $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']); + $this->assertSame([10, 11], array_column($repository->statusUpdates, 'id')); } - public function testSyncDoesNothingWhenSelectedInvoiceListIsEmpty() + public function testSyncDoesNotQueryWhenSelectionIsEmpty() { - $repository = new FakeKSeFRepository([], [ - $this->pendingDocument(), - ]); + $repository = new FakeKSeFRepository([], [$this->pendingDocument()]); $gateway = new FakeKSeFGateway(); - $service = $this->service($repository, $gateway); - $result = $service->sync($this->ksefConfig(), null, null, []); + $result = $this->service($repository, $gateway)->sync($this->config(), null, null, []); - $this->assertSame(0, $result['updated']); - $this->assertSame([], $result['errors']); + $this->assertSame(['updated' => 0, 'errors' => []], $result); $this->assertSame(0, $repository->pendingQueryCount); $this->assertSame([], $gateway->listedSessions); } - public function testSyncLoadsDivisionConfigOnlyOncePerRun() - { - $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(); - $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' => 0]; - $gateway->invoiceStatuses['SESSION-1:INVOICE-2'] = ['status' => 0]; - $configCalls = 0; - $service = $this->service( - $repository, - $gateway, - null, - function () use (&$configCalls) { - $configCalls++; - - return $this->ksefConfig(); - } - ); - - $result = $service->sync($this->ksefConfig()); - - $this->assertSame(2, $result['updated']); - $this->assertSame(1, $configCalls); - } - public function testSyncRejectsDocumentWithoutSellerTenBeforeCallingKSeF() { - $repository = new FakeKSeFRepository([], [ - $this->pendingDocument(['seller_ten' => '']), - ]); + $repository = new FakeKSeFRepository([], [$this->pendingDocument(['seller_ten' => ''])]); $gateway = new FakeKSeFGateway(); - $service = $this->service($repository, $gateway); - $result = $service->sync($this->ksefConfig()); + $result = $this->service($repository, $gateway)->sync($this->config()); $this->assertSame(0, $result['updated']); $this->assertSame('Missing seller TEN.', $result['errors'][0]['error']); @@ -780,92 +298,44 @@ public function testSyncRejectsDocumentWithoutSellerTenBeforeCallingKSeF() public function testSyncKeepsDocumentPendingWhenUpoCannotBeSaved() { - $repository = new FakeKSeFRepository([], [ - $this->pendingDocument(), - ]); + $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'] = [ + $gateway->sessionInvoices['SESSION-1'] = [$this->remoteInvoice(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()); + $result = $this->service($repository, $gateway)->sync($this->config()); $this->assertSame(0, $result['updated']); $this->assertSame('UPO save failed', $result['errors'][0]['error']); $this->assertSame([], $repository->statusUpdates); } - public function testSyncTreatsDuplicateInvoiceStatusWithOriginalKsefNumberAsAccepted() + public function testSyncRecoversOriginalNumberAndUpoForDuplicate() { - $repository = new FakeKSeFRepository([], [ - $this->pendingDocument(), - ]); + $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', - ]; - $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'] = [ + $gateway->sessionInvoices['SESSION-1'] = [$this->remoteInvoice(1, [ 'status' => 440, 'status_description' => 'Duplikat faktury', 'status_details' => 'Duplikat faktury.', 'original_ksef_number' => '1234567890-20260424-ABCDEF', 'upo' => '', - ]; - $service = $this->service($repository, $gateway); + ])]; - $result = $service->sync($this->ksefConfig()); + $result = $this->service($repository, $gateway)->sync($this->config()); $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('Duplikat faktury', $repository->statusUpdates[0]['status_description']); $this->assertSame('', $repository->savedUpos[0]['content']); } - private function ksefConfig(string $environment = 'test', string $token = 'secret-token', int $maxDocuments = 10000): KSeFConfig + private function config(string $environment = 'test', string $token = 'secret-token', int $maxDocuments = 10000): KSeFConfig { return KSeFConfig::fromArray([ 'environment' => $environment, @@ -888,13 +358,18 @@ 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 remoteInvoice(int $ordinalNumber, array $overrides = []): array + { + return array_merge([ + 'ordinal_number' => $ordinalNumber, + 'status' => 0, ], $overrides); } @@ -917,17 +392,17 @@ private function service( ); } - private function expectedInvoiceReferenceLookupCount(): int + private function expectedInvoiceLookupCount(): 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) + for ($attempt = 1; $waitedSeconds < KSeFSubmissionService::INVOICE_LIST_WAIT_SECONDS; $attempt++) { + $sleepSeconds = KSeFSubmissionService::INVOICE_LIST_RETRY_SECONDS[ + min($attempt - 1, count(KSeFSubmissionService::INVOICE_LIST_RETRY_SECONDS) - 1) ]; $waitedSeconds += min( $sleepSeconds, - KSeFSubmissionService::INVOICE_REFERENCE_WAIT_SECONDS - $waitedSeconds + KSeFSubmissionService::INVOICE_LIST_WAIT_SECONDS - $waitedSeconds ); $lookupCount++; } diff --git a/tests/lib/KSeF/KSeFTest.php b/tests/lib/KSeF/KSeFTest.php index 9993ca8b5b..5c29e4200a 100644 --- a/tests/lib/KSeF/KSeFTest.php +++ b/tests/lib/KSeF/KSeFTest.php @@ -59,11 +59,8 @@ if (!class_exists('PHPUnit\Framework\TestCase') && class_exists('PHPUnit_Framework_TestCase')) { class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); } - require_once __DIR__ . '/ConfigHelper.php'; - require_once __DIR__ . '/Localisation.php'; - require_once __DIR__ . '/LMS.php'; - require_once __DIR__ . '/Utils.php'; - require_once __DIR__ . '/FakeKSeFLms.php'; + require_once __DIR__ . '/../../../lib/LMS.class.php'; + require_once __DIR__ . '/../../../lib/Utils.php'; if (!function_exists('bankaccount')) { function bankaccount($customerId, $account) { @@ -260,7 +257,8 @@ private function ksefXmlGenerator() { $reflection = new \ReflectionClass(KSeF::class); $ksef = $reflection->newInstanceWithoutConstructor(); - $this->setKSeFProperty($ksef, 'lms', new FakeKSeFLms()); + $lmsClass = new \ReflectionClass(\LMS::class); + $this->setKSeFProperty($ksef, 'lms', $lmsClass->newInstanceWithoutConstructor()); $this->setKSeFProperty($ksef, 'divisions', [ 1 => [ 'email' => '', diff --git a/tests/lib/KSeF/LMS.php b/tests/lib/KSeF/LMS.php deleted file mode 100644 index 0119644869..0000000000 --- a/tests/lib/KSeF/LMS.php +++ /dev/null @@ -1,9 +0,0 @@ -invoke( $gateway, - new FakeXmlValidationException('The value is not valid with xsd.', [ - 'errors' => [$error], - ]) + new XmlValidationException( + 'The value is not valid with xsd.', + 0, + null, + ['errors' => [$error]] + ) ); $this->assertSame( diff --git a/tests/lib/KSeF/Utils.php b/tests/lib/KSeF/Utils.php deleted file mode 100644 index 168f0551b1..0000000000 --- a/tests/lib/KSeF/Utils.php +++ /dev/null @@ -1,16 +0,0 @@ - Date: Mon, 27 Jul 2026 15:16:16 +0200 Subject: [PATCH 15/17] refactor: use production KSeF components in tests --- bin/lms-ksef.php | 4 +- lib/KSeF/KSeFConfig.php | 2 +- lib/KSeF/N1ebieskiKSeFGateway.php | 77 +++---- tests/lib/KSeF/FakeKSeFGateway.php | 14 +- tests/lib/KSeF/FakeKSeFRepository.php | 60 ++--- tests/lib/KSeF/FakeKsefUpoClient.php | 45 ---- tests/lib/KSeF/KSeFConfigTest.php | 11 - tests/lib/KSeF/KSeFSubmissionServiceTest.php | 51 ++--- tests/lib/KSeF/KSeFTest.php | 44 +--- tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php | 226 +++++++++---------- 10 files changed, 182 insertions(+), 352 deletions(-) delete mode 100644 tests/lib/KSeF/FakeKsefUpoClient.php diff --git a/bin/lms-ksef.php b/bin/lms-ksef.php index 5c7a7811b8..fcdc0e5765 100755 --- a/bin/lms-ksef.php +++ b/bin/lms-ksef.php @@ -62,12 +62,12 @@ ConfigHelper::setFilter($divisionId); } $customerId = isset($options['customerid']) ? intval($options['customerid']) : null; -$configProvider = function (?int $selectedDivisionId = null) use ($options) { +$configProvider = function (?int $selectedDivisionId = null) { if ($selectedDivisionId !== null) { ConfigHelper::setFilter($selectedDivisionId); } - return KSeFConfig::fromConfigHelper(!isset($options['test'])); + return KSeFConfig::fromConfigHelper(); }; $config = KSeFConfig::fromConfigHelper(false); diff --git a/lib/KSeF/KSeFConfig.php b/lib/KSeF/KSeFConfig.php index 6cdde30381..ad72d39825 100644 --- a/lib/KSeF/KSeFConfig.php +++ b/lib/KSeF/KSeFConfig.php @@ -28,7 +28,7 @@ public static function fromArray(array $config, bool $validateCredentials = true { $environment = self::parseEnvironment($config['environment'] ?? 'test'); $token = self::nullableString($config['token'] ?? null); - $certificatePath = self::nullableString($config['certificate_path'] ?? $config['certificate'] ?? null); + $certificatePath = self::nullableString($config['certificate_path'] ?? null); $certificatePassword = self::nullableString($config['certificate_password'] ?? null); $maxDocuments = min(10000, max(1, (int) ($config['max_documents'] ?? 10000))); diff --git a/lib/KSeF/N1ebieskiKSeFGateway.php b/lib/KSeF/N1ebieskiKSeFGateway.php index b98f5c6ade..59abb1b230 100644 --- a/lib/KSeF/N1ebieskiKSeFGateway.php +++ b/lib/KSeF/N1ebieskiKSeFGateway.php @@ -41,7 +41,12 @@ public function sendXmlBatch(KSeFConfig $config, string $sellerTen, array $xmlDo ->openAndSend(new OpenAndSendXmlRequest(FormCode::Fa3, $xmlDocuments)) ->object(); - return $this->readStringProperty($response, 'referenceNumber'); + $referenceNumber = $response->referenceNumber ?? null; + if (!is_string($referenceNumber) || $referenceNumber === '') { + throw new \RuntimeException('KSeF response does not contain referenceNumber.'); + } + + return $referenceNumber; } public function closeBatchSession(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): void @@ -80,10 +85,11 @@ public function listInvoices(KSeFConfig $config, string $sellerTen, string $sess $statusCode = (int) ($status->code ?? 0); $statusDetails = $this->extractStatusDetails($invoice); $ksefNumber = $invoice->ksefNumber ?? null; - $originalKsefNumber = $status->extensions->originalKsefNumber - ?? $this->extractOriginalKsefNumberFromDetails($statusDetails); - $originalSessionReferenceNumber = $status->extensions->originalSessionReferenceNumber - ?? $this->extractOriginalSessionReferenceFromDetails($statusDetails); + [$originalKsefNumber, $originalSessionReferenceNumber] = + $this->extractDuplicateReferences($statusDetails); + $originalKsefNumber = $status?->extensions?->originalKsefNumber ?? $originalKsefNumber; + $originalSessionReferenceNumber = $status?->extensions?->originalSessionReferenceNumber + ?? $originalSessionReferenceNumber; $upo = null; if ($statusCode === KSeF::STATUS_ACCEPTED && !empty($ksefNumber)) { @@ -95,7 +101,8 @@ public function listInvoices(KSeFConfig $config, string $sellerTen, string $sess ReferenceNumber::from($invoice->referenceNumber) )) ->body(); - } elseif ($statusCode === 440 + } elseif ( + $statusCode === 440 && !empty($originalKsefNumber) && !empty($originalSessionReferenceNumber) ) { @@ -112,7 +119,7 @@ public function listInvoices(KSeFConfig $config, string $sellerTen, string $sess 'status_description' => $status->description ?? null, 'status_details' => $statusDetails, 'ksef_number' => $ksefNumber, - 'permanent_storage_date' => $this->extractPermanentStorageDate($invoice), + 'permanent_storage_date' => $invoice->permanentStorageDate ?? null, 'original_ksef_number' => $originalKsefNumber, 'upo' => $upo, ]; @@ -133,7 +140,7 @@ public function listInvoices(KSeFConfig $config, string $sellerTen, string $sess return $invoices; } - private function buildClient(KSeFConfig $config, ?string $sellerTen = null) + private function buildClient(KSeFConfig $config, string $sellerTen) { if (!class_exists('\N1ebieski\KSEFClient\ClientBuilder')) { throw new \RuntimeException('Missing n1ebieski/ksef-php-client dependency. Run composer install.'); @@ -149,9 +156,7 @@ private function buildClient(KSeFConfig $config, ?string $sellerTen = null) ->withEncryptionKey(\N1ebieski\KSEFClient\Factories\EncryptionKeyFactory::makeRandom()) ->withValidateXml(false); - if ($sellerTen !== null && $sellerTen !== '') { - $builder = $builder->withIdentifier($sellerTen); - } + $builder = $builder->withIdentifier($sellerTen); if ($config->usesApiToken()) { $builder = $builder->withKsefToken($config->getToken()); @@ -235,15 +240,6 @@ private function createInvoiceListRequest( ); } - 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)) { @@ -255,40 +251,19 @@ private function extractStatusDetails($response): ?string 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 + private function extractDuplicateReferences(?string $statusDetails): array { - foreach (['permanentStorageDate', 'invoicingDate', 'acquisitionTimestamp'] as $field) { - if (!empty($response->{$field})) { - return (string) $response->{$field}; + $ksefNumber = null; + $sessionReferenceNumber = null; + if ($statusDetails !== null) { + if (preg_match('/\b[0-9]{10}-[0-9]{8}-[A-Z0-9]{12}-[A-Z0-9]{2}\b/i', $statusDetails, $matches)) { + $ksefNumber = strtoupper($matches[0]); + } + 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)) { + $sessionReferenceNumber = strtoupper($matches[0]); } } - return null; + return [$ksefNumber, $sessionReferenceNumber]; } } diff --git a/tests/lib/KSeF/FakeKSeFGateway.php b/tests/lib/KSeF/FakeKSeFGateway.php index fdeb246200..fe9dc749b5 100644 --- a/tests/lib/KSeF/FakeKSeFGateway.php +++ b/tests/lib/KSeF/FakeKSeFGateway.php @@ -9,9 +9,9 @@ class FakeKSeFGateway implements KSeFGatewayInterface { public $closedBatchSessions = []; public $sentXmlBatches = []; - public $sentConfigs = []; + public $sentTokens = []; public $listedSessions = []; - public $listedConfigs = []; + public $listedTokens = []; public $sessionInvoices = []; public $failClose = false; public $invalidXmlDocuments = []; @@ -27,10 +27,7 @@ public function validateXml(string $xml): void public function sendXmlBatch(KSeFConfig $config, string $sellerTen, array $xmlDocuments): string { $this->sentXmlBatches[] = $xmlDocuments; - $this->sentConfigs[] = [ - 'environment' => $config->getEnvironment(), - 'token' => $config->getToken(), - ]; + $this->sentTokens[] = $config->getToken(); return 'SESSION-' . count($this->sentXmlBatches); } @@ -47,10 +44,7 @@ public function closeBatchSession(KSeFConfig $config, string $sellerTen, string public function listInvoices(KSeFConfig $config, string $sellerTen, string $sessionReferenceNumber): array { $this->listedSessions[] = $sessionReferenceNumber; - $this->listedConfigs[] = [ - 'environment' => $config->getEnvironment(), - 'token' => $config->getToken(), - ]; + $this->listedTokens[] = $config->getToken(); if (!empty($this->invoiceResponseSequences[$sessionReferenceNumber])) { return array_shift($this->invoiceResponseSequences[$sessionReferenceNumber]); } diff --git a/tests/lib/KSeF/FakeKSeFRepository.php b/tests/lib/KSeF/FakeKSeFRepository.php index 2720b050aa..36703358a6 100644 --- a/tests/lib/KSeF/FakeKSeFRepository.php +++ b/tests/lib/KSeF/FakeKSeFRepository.php @@ -6,8 +6,7 @@ class FakeKSeFRepository implements KSeFRepositoryInterface { - public $sessions = []; - public $documents = []; + public $reservations = []; public $sessionReferenceUpdates = []; public $sessionCloseUpdates = []; public $discardedSessions = []; @@ -17,11 +16,11 @@ class FakeKSeFRepository implements KSeFRepositoryInterface public $failUpoSave = false; public $failSessionReferenceUpdate = false; public $eligibleDocIds = null; + public $eligibleLimit = null; public $pendingDivisionId = null; public $pendingCustomerId = null; public $pendingDocIds = null; - public $eligibleQueryCount = 0; - public $pendingQueryCount = 0; + public $pendingLimit = null; private $eligibleInvoices; private $pendingDocuments; @@ -38,19 +37,10 @@ public function getEligibleInvoices( ?int $customerId = null, ?array $docIds = null ): array { - $this->eligibleQueryCount++; $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); - } - ); - } + $this->eligibleLimit = $limit; - return array_slice(array_values($eligibleInvoices), 0, $limit); + return $this->eligibleInvoices; } public function reserveInvoices(array $documents, int $environment, int $createdAt): array @@ -62,27 +52,18 @@ public function reserveInvoices(array $documents, int $environment, int $created ]; } - $this->sessions[] = [ + $this->reservations[] = [ + 'documents' => $documents, 'environment' => $environment, ]; - $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, - ]; - $reservedDocuments[] = [ - 'docid' => (int) $document['docid'], - ]; - } + $sessionId = count($this->reservations); return [ 'session_id' => $sessionId, - 'documents' => $reservedDocuments, + 'documents' => array_map( + fn (array $document): array => ['docid' => (int) $document['docid']], + $documents + ), 'skipped' => [], ]; } @@ -101,9 +82,7 @@ public function updateSessionReference(int $id, string $referenceNumber): void public function closeSession(int $id): void { - $this->sessionCloseUpdates[] = [ - 'id' => $id, - ]; + $this->sessionCloseUpdates[] = $id; } public function discardSession(int $id): void @@ -117,21 +96,12 @@ public function getPendingDocuments( ?int $customerId = null, ?array $docIds = null ): array { - $this->pendingQueryCount++; $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); - } - ); - } + $this->pendingLimit = $limit; - return array_slice(array_values($pendingDocuments), 0, $limit); + return $this->pendingDocuments; } public function updateDocumentStatus( diff --git a/tests/lib/KSeF/FakeKsefUpoClient.php b/tests/lib/KSeF/FakeKsefUpoClient.php deleted file mode 100644 index db0cd16800..0000000000 --- a/tests/lib/KSeF/FakeKsefUpoClient.php +++ /dev/null @@ -1,45 +0,0 @@ -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/KSeFConfigTest.php b/tests/lib/KSeF/KSeFConfigTest.php index 64f831f524..ea8534e3e9 100644 --- a/tests/lib/KSeF/KSeFConfigTest.php +++ b/tests/lib/KSeF/KSeFConfigTest.php @@ -45,17 +45,6 @@ public function testBuildsProductionCertificateConfigFromArray() $this->assertSame(10000, $config->getMaxDocuments()); } - public function testInfersTokenAuthWhenTokenIsConfigured() - { - $config = KSeFConfig::fromArray([ - 'environment' => 'test', - 'token' => 'secret-token', - ]); - - $this->assertTrue($config->usesApiToken()); - $this->assertSame('secret-token', $config->getToken()); - } - public function testRecognizesStandardLmsCertificateSettingWhenItContainsApiToken() { $token = str_repeat('a', 64); diff --git a/tests/lib/KSeF/KSeFSubmissionServiceTest.php b/tests/lib/KSeF/KSeFSubmissionServiceTest.php index 5d52d0c333..783be58199 100644 --- a/tests/lib/KSeF/KSeFSubmissionServiceTest.php +++ b/tests/lib/KSeF/KSeFSubmissionServiceTest.php @@ -38,18 +38,17 @@ public function testSendReservesAndSubmitsOneBatchPerSellerAndDivision() $result = $service->send(KSeFConfig::fromArray(['environment' => 'test'], false)); $this->assertSame(['submitted' => 3, 'skipped' => 0, 'errors' => []], $result); - $this->assertCount(2, $repository->sessions); - $this->assertSame([KSeF::ENVIRONMENT_TEST, KSeF::ENVIRONMENT_PROD], array_column($repository->sessions, 'environment')); - $this->assertSame(['division-7-token', 'division-8-token'], array_column($gateway->sentConfigs, 'token')); + $this->assertCount(2, $repository->reservations); + $this->assertSame([KSeF::ENVIRONMENT_TEST, KSeF::ENVIRONMENT_PROD], array_column($repository->reservations, 'environment')); + $this->assertSame(['division-7-token', 'division-8-token'], $gateway->sentTokens); $this->assertSame([ '123', '124', ], $gateway->sentXmlBatches[0]); $this->assertSame(['125'], $gateway->sentXmlBatches[1]); - $this->assertSame([1, 2, 1], array_column($repository->documents, 'ordinalnumber')); $this->assertSame( - base64_encode(hash('sha256', '123', true)), - $repository->documents[0]['hash'] + 'Cq4ssQtAVcbVa8bW5amkaOK0hNNzB6Pfthlb+vOQYOQ=', + $repository->reservations[0]['documents'][0]['hash'] ); $this->assertSame(['SESSION-1', 'SESSION-2'], $gateway->closedBatchSessions); $this->assertCount(2, $repository->sessionCloseUpdates); @@ -72,6 +71,7 @@ public function testSendHonoursExplicitSelectionInsteadOfConfiguredLimit() $this->assertSame(2, $result['submitted']); $this->assertSame([123, 124], $repository->eligibleDocIds); + $this->assertSame(2, $repository->eligibleLimit); $this->assertCount(2, $gateway->sentXmlBatches[0]); } @@ -83,7 +83,7 @@ public function testSendDoesNotQueryWhenSelectionIsEmpty() $result = $this->service($repository, $gateway)->send($this->config(), null, null, []); $this->assertSame(['submitted' => 0, 'skipped' => 0, 'errors' => []], $result); - $this->assertSame(0, $repository->eligibleQueryCount); + $this->assertNull($repository->eligibleLimit); $this->assertSame([], $gateway->sentXmlBatches); } @@ -205,7 +205,7 @@ public function testSyncUsesDivisionConfigAndSelectionFilters() $this->assertSame(8, $repository->pendingDivisionId); $this->assertSame(123, $repository->pendingCustomerId); $this->assertSame(1, $configCalls); - $this->assertSame('division-8-token', $gateway->listedConfigs[0]['token']); + $this->assertSame('division-8-token', $gateway->listedTokens[0]); } public function testSyncWaitsUntilAllSelectedSessionInvoicesAreVisible() @@ -240,12 +240,22 @@ public function testSyncUsesOneWaitWindowForAllMissingDocumentsInSession() $this->pendingDocument(['id' => 11, 'ordinalnumber' => 2]), ]); $gateway = new FakeKSeFGateway(); + $sleeps = []; - $result = $this->service($repository, $gateway)->sync($this->config()); + $result = $this->service( + $repository, + $gateway, + null, + null, + function (int $seconds) use (&$sleeps) { + $sleeps[] = $seconds; + } + )->sync($this->config()); $this->assertSame(0, $result['updated']); $this->assertCount(2, $result['errors']); - $this->assertCount($this->expectedInvoiceLookupCount(), $gateway->listedSessions); + $this->assertSame(KSeFSubmissionService::INVOICE_LIST_WAIT_SECONDS, array_sum($sleeps)); + $this->assertCount(count($sleeps) + 1, $gateway->listedSessions); } public function testSyncHonoursExplicitSelectionInsteadOfConfiguredLimit() @@ -269,6 +279,7 @@ public function testSyncHonoursExplicitSelectionInsteadOfConfiguredLimit() $this->assertSame(2, $result['updated']); $this->assertSame([123, 124], $repository->pendingDocIds); + $this->assertSame(2, $repository->pendingLimit); $this->assertSame([10, 11], array_column($repository->statusUpdates, 'id')); } @@ -280,7 +291,7 @@ public function testSyncDoesNotQueryWhenSelectionIsEmpty() $result = $this->service($repository, $gateway)->sync($this->config(), null, null, []); $this->assertSame(['updated' => 0, 'errors' => []], $result); - $this->assertSame(0, $repository->pendingQueryCount); + $this->assertNull($repository->pendingLimit); $this->assertSame([], $gateway->listedSessions); } @@ -391,22 +402,4 @@ private function service( } ); } - - private function expectedInvoiceLookupCount(): int - { - $waitedSeconds = 0; - $lookupCount = 1; - for ($attempt = 1; $waitedSeconds < KSeFSubmissionService::INVOICE_LIST_WAIT_SECONDS; $attempt++) { - $sleepSeconds = KSeFSubmissionService::INVOICE_LIST_RETRY_SECONDS[ - min($attempt - 1, count(KSeFSubmissionService::INVOICE_LIST_RETRY_SECONDS) - 1) - ]; - $waitedSeconds += min( - $sleepSeconds, - KSeFSubmissionService::INVOICE_LIST_WAIT_SECONDS - $waitedSeconds - ); - $lookupCount++; - } - - return $lookupCount; - } } diff --git a/tests/lib/KSeF/KSeFTest.php b/tests/lib/KSeF/KSeFTest.php index 5c29e4200a..fdf39c3da1 100644 --- a/tests/lib/KSeF/KSeFTest.php +++ b/tests/lib/KSeF/KSeFTest.php @@ -14,46 +14,16 @@ define('DOC_CNOTE', 3); } if (!defined('DOC_FLAG_SPLIT_PAYMENT')) { - define('DOC_FLAG_SPLIT_PAYMENT', 1); + define('DOC_FLAG_SPLIT_PAYMENT', 8); } if (!defined('DOC_FLAG_RECEIPT')) { - define('DOC_FLAG_RECEIPT', 2); + define('DOC_FLAG_RECEIPT', 1); } if (!defined('DOC_FLAG_RELATED_ENTITY')) { define('DOC_FLAG_RELATED_ENTITY', 4); } - if (!defined('PAYTYPE_CASH')) { - define('PAYTYPE_CASH', 1); - } - if (!defined('PAYTYPE_CARD')) { - define('PAYTYPE_CARD', 2); - } - if (!defined('PAYTYPE_BANK_LOAN')) { - define('PAYTYPE_BANK_LOAN', 3); - } if (!defined('PAYTYPE_TRANSFER')) { - define('PAYTYPE_TRANSFER', 4); - } - if (!defined('PAYTYPE_BARTER')) { - define('PAYTYPE_BARTER', 5); - } - if (!defined('PAYTYPE_CASH_ON_DELIVERY')) { - define('PAYTYPE_CASH_ON_DELIVERY', 6); - } - if (!defined('PAYTYPE_COMPENSATION')) { - define('PAYTYPE_COMPENSATION', 7); - } - if (!defined('PAYTYPE_CONTRACT')) { - define('PAYTYPE_CONTRACT', 8); - } - if (!defined('PAYTYPE_INSTALMENTS')) { - define('PAYTYPE_INSTALMENTS', 9); - } - if (!defined('PAYTYPE_PAID')) { - define('PAYTYPE_PAID', 10); - } - if (!defined('PAYTYPE_TRANSFER_CASH')) { - define('PAYTYPE_TRANSFER_CASH', 11); + define('PAYTYPE_TRANSFER', 2); } if (!class_exists('PHPUnit\Framework\TestCase') && class_exists('PHPUnit_Framework_TestCase')) { @@ -61,12 +31,6 @@ class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); } require_once __DIR__ . '/../../../lib/LMS.class.php'; require_once __DIR__ . '/../../../lib/Utils.php'; - if (!function_exists('bankaccount')) { - function bankaccount($customerId, $account) - { - return $account; - } - } } namespace LMS\Tests\KSeF { @@ -245,10 +209,8 @@ private function invoiceFixture() 'ksefshowbalancesummary' => 0, 'ksefxmladdallvalues' => 0, 'paytype' => PAYTYPE_TRANSFER, - 'account' => '11111111111111111111111111', 'export' => false, 'division_bank' => '', - 'bankaccounts' => [], 'extid' => '', ]; } diff --git a/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php index 65cb4ca47a..1e61265042 100644 --- a/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php +++ b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php @@ -1,129 +1,121 @@ 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); - - $sessionReferenceNumber = '20260424-SO-ABCDEFGHIJ-1234567890-AB'; - $request = $method->invoke($gateway, $sessionReferenceNumber, 'NEXT-PAGE'); - - $this->assertInstanceOf(ListRequest::class, $request); - $this->assertSame($sessionReferenceNumber, $request->referenceNumber->value); - $this->assertSame(1000, $request->pageSize->value); - $this->assertInstanceOf(ContinuationToken::class, $request->continuationToken); - $this->assertSame('NEXT-PAGE', $request->continuationToken->value); - } - - 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 XmlValidationException( - 'The value is not valid with xsd.', - 0, - null, - ['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' - ); + $listResponse = new ListResponseFixture(); + unset($listResponse->data['continuationToken']); + $upoResponse = new UpoResponseFixture(); + $duplicateUpoResponse = new KsefUpoResponseFixture(); + $history = []; + $config = $this->ksefConfig(); + $gateway = $this->gatewayWithResponses($config, [ + new Response($listResponse->statusCode, [], $listResponse->toContents()), + new Response($upoResponse->statusCode, [], $upoResponse->toContents()), + new Response($duplicateUpoResponse->statusCode, [], $duplicateUpoResponse->toContents()), + ], $history); + $sessionReference = (new ListRequestFixture())->data['referenceNumber']; + + $invoices = $gateway->listInvoices($config, '5265877635', $sessionReference); + + $this->assertCount(2, $invoices); + $this->assertSame(1, $invoices[0]['ordinal_number']); + $this->assertSame(200, $invoices[0]['status']); + $this->assertSame('5265877635-20250626-010080DD2B5E-26', $invoices[0]['ksef_number']); + $this->assertSame('2025-09-18T12:24:01.0154302+00:00', $invoices[0]['permanent_storage_date']); + $this->assertSame('upo', $invoices[0]['upo']); + $this->assertSame(2, $invoices[1]['ordinal_number']); + $this->assertSame(440, $invoices[1]['status']); + $this->assertSame('5265877635-20250626-010080DD2B5E-26', $invoices[1]['original_ksef_number']); + $this->assertNull($invoices[1]['permanent_storage_date']); + $this->assertSame('upo', $invoices[1]['upo']); + + $this->assertCount(3, $history); + $this->assertStringEndsWith('/sessions/' . $sessionReference . '/invoices', $history[0]['request']->getUri()->getPath()); + parse_str($history[0]['request']->getUri()->getQuery(), $query); + $this->assertSame('1000', $query['pageSize']); + $this->assertStringEndsWith('/invoices/' . $listResponse->data['invoices'][0]['referenceNumber'] . '/upo', $history[1]['request']->getUri()->getPath()); + $this->assertStringEndsWith( + '/sessions/20250626-SO-2F14610000-242991F8C9-B4/invoices/ksef/' + . '5265877635-20250626-010080DD2B5E-26/upo', + $history[2]['request']->getUri()->getPath() + ); + } - $this->assertSame('5265877635-20250626-010080DD2B5E-26', $result); - } + public function testDuplicateUpoFailureDoesNotBlockStatusMapping() + { + $listResponse = new ListResponseFixture(); + unset($listResponse->data['continuationToken']); + $listResponse->data['invoices'] = [$listResponse->data['invoices'][1]]; + $history = []; + $config = $this->ksefConfig(); + $gateway = $this->gatewayWithResponses($config, [ + new Response($listResponse->statusCode, [], $listResponse->toContents()), + new Response(500), + ], $history); + $sessionReference = (new ListRequestFixture())->data['referenceNumber']; + + $invoices = $gateway->listInvoices($config, '5265877635', $sessionReference); + + $this->assertSame(440, $invoices[0]['status']); + $this->assertSame('5265877635-20250626-010080DD2B5E-26', $invoices[0]['original_ksef_number']); + $this->assertNull($invoices[0]['upo']); + } - public function testExtractsOriginalSessionReferenceFromDuplicateStatusDetails() - { - $gateway = new N1ebieskiKSeFGateway(); - $method = new \ReflectionMethod($gateway, 'extractOriginalSessionReferenceFromDetails'); - $method->setAccessible(true); + public function testFormatsRealXmlValidationErrorsWithLineAndColumn() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('line 1, column 28'); - $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' - ); + (new N1ebieskiKSeFGateway())->validateXml(''); + } - $this->assertSame('20250626-SO-2F14610000-242991F8C9-B4', $result); - } + private function ksefConfig(): KSeFConfig + { + return KSeFConfig::fromArray([ + 'environment' => 'test', + 'token' => 'secret-token', + ]); } + private function gatewayWithResponses(KSeFConfig $ksefConfig, array $responses, array &$history): N1ebieskiKSeFGateway + { + $handler = HandlerStack::create(new MockHandler($responses)); + $handler->push(Middleware::history($history)); + $client = (new ClientBuilder()) + ->withMode(Mode::Test) + ->withHttpClient(new GuzzleClient(['handler' => $handler])) + ->withValidateXml(false) + ->build(); + $gateway = new N1ebieskiKSeFGateway(); + $clients = new \ReflectionProperty($gateway, 'clients'); + $clients->setAccessible(true); + $clients->setValue($gateway, [ + spl_object_hash($ksefConfig) . ':5265877635' => $client, + ]); + + return $gateway; + } } From 64dcf33d32019760487e954574b30ba902f980b7 Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Jul 2026 15:56:42 +0200 Subject: [PATCH 16/17] refactor: trim KSeF submission implementation --- bin/lms-ksef.php | 3 +- lib/KSeF/KSeF.php | 78 +++++++++--------- lib/KSeF/KSeFConfig.php | 24 ++---- lib/KSeF/KSeFRepository.php | 19 +---- lib/KSeF/KSeFRepositoryInterface.php | 2 +- lib/KSeF/KSeFSubmissionService.php | 85 ++++++-------------- lib/KSeF/N1ebieskiKSeFGateway.php | 66 +++++++-------- lib/locale/pl_PL/strings.php | 3 - modules/invoiceksefinfo.php | 24 +++--- tests/lib/KSeF/FakeKSeFRepository.php | 2 +- tests/lib/KSeF/KSeFConfigTest.php | 4 - tests/lib/KSeF/KSeFSubmissionServiceTest.php | 6 +- tests/lib/KSeF/KSeFTest.php | 23 +++++- tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php | 4 - 14 files changed, 144 insertions(+), 199 deletions(-) diff --git a/bin/lms-ksef.php b/bin/lms-ksef.php index fcdc0e5765..9540428698 100755 --- a/bin/lms-ksef.php +++ b/bin/lms-ksef.php @@ -83,11 +83,10 @@ exit(0); } -$gateway = new N1ebieskiKSeFGateway(); $ksef = new KSeF($DB, $LMS); $service = new KSeFSubmissionService( $repository, - $gateway, + new N1ebieskiKSeFGateway(), function (array $invoice) use ($LMS, $ksef) { $invoiceContent = $LMS->GetInvoiceContent((int) $invoice['id']); if (empty($invoiceContent)) { diff --git a/lib/KSeF/KSeF.php b/lib/KSeF/KSeF.php index d4409ddc6d..0024f7b38a 100644 --- a/lib/KSeF/KSeF.php +++ b/lib/KSeF/KSeF.php @@ -26,6 +26,8 @@ namespace Lms\KSeF; +use N1ebieski\KSEFClient\ValueObjects\Requests\KsefNumber; + class KSeF { const CERTIFICATE_FORMAT_UNKNOWN = 0; @@ -40,6 +42,7 @@ class KSeF const ENVIRONMENT_DEMO = 3; const STATUS_PENDING = 0; const STATUS_ACCEPTED = 200; + const STATUS_DUPLICATE = 440; const IDENTIFIER_TEN = 1; const IDENTIFIER_VAT_UE = 2; @@ -430,7 +433,9 @@ public function getInvoiceXml(array $invoice) private function buildInvoiceXml(array $invoice) { - $invoiceType = $invoice['type'] ?? $invoice['doctype'] ?? null; + if (!isset($invoice['type']) && isset($invoice['doctype'])) { + $invoice['type'] = $invoice['doctype']; + } if (!isset($this->divisions[$invoice['divisionid']])) { $this->divisions[$invoice['divisionid']] = $this->lms->GetDivision($invoice['divisionid']); @@ -555,7 +560,7 @@ private function buildInvoiceXml(array $invoice) $xml .= "\t\t" . $invoice['customerid'] . "" . PHP_EOL; - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { $buyerUuid = \Ramsey\Uuid\Uuid::uuid4(); $buyerUuid = $buyerUuid->getHex(); $xml .= "\t\t" . $buyerUuid . "" . PHP_EOL; @@ -601,7 +606,7 @@ private function buildInvoiceXml(array $invoice) $xml .= "\t" . PHP_EOL; - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { $recipientUuid = \Ramsey\Uuid\Uuid::uuid4(); $recipientUuid = $recipientUuid->getHex(); $xml .= "\t\t" . $recipientUuid . "" . PHP_EOL; @@ -685,7 +690,7 @@ private function buildInvoiceXml(array $invoice) $xml .= "\t" . PHP_EOL; - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { $recipientUuid2 = \Ramsey\Uuid\Uuid::uuid4(); $recipientUuid2 = $recipientUuid2->getHex(); $xml .= "\t\t" . $recipientUuid2 . "" . PHP_EOL; @@ -766,7 +771,7 @@ private function buildInvoiceXml(array $invoice) $taxFree = false; $diffTotal = 0; - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == 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'])) { @@ -812,7 +817,7 @@ private function buildInvoiceXml(array $invoice) } } - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == 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'])) { @@ -858,7 +863,7 @@ private function buildInvoiceXml(array $invoice) } } - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { if (isset($invoice['taxest']['5.00']) || isset($invoice['invoice']['taxest']['5.00'])) { $taxRate = '5.00'; } else { @@ -902,7 +907,7 @@ private function buildInvoiceXml(array $invoice) $taxRate = '0.00'; if ($ue || $foreign) { - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { if (isset($invoice['taxest'][$taxRate]) || isset($invoice['invoice']['taxest'][$taxRate])) { if (isset($invoice['taxest'][$taxRate])) { $base = round(($invoice['taxest'][$taxRate]['base'] - (isset($invoice['invoice']['taxest'][$taxRate]) ? $invoice['invoice']['taxest'][$taxRate]['base'] : 0)), 2); @@ -932,7 +937,7 @@ private function buildInvoiceXml(array $invoice) } } } else { - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { if (isset($invoice['taxest'][$taxRate]) || isset($invoice['invoice']['taxest'][$taxRate])) { if (isset($invoice['taxest'][$taxRate])) { $base = round(($invoice['taxest'][$taxRate]['base'] - (isset($invoice['invoice']['taxest'][$taxRate]) ? $invoice['invoice']['taxest'][$taxRate]['base'] : 0)), 2); @@ -952,7 +957,7 @@ private function buildInvoiceXml(array $invoice) } $taxRate = '-1'; - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { if (isset($invoice['taxest'][$taxRate]) || isset($invoice['invoice']['taxest'][$taxRate])) { if (isset($invoice['taxest'][$taxRate])) { $base = round(($invoice['taxest'][$taxRate]['base'] - (isset($invoice['invoice']['taxest'][$taxRate]) ? $invoice['invoice']['taxest'][$taxRate]['base'] : 0)), 2); @@ -986,7 +991,7 @@ private function buildInvoiceXml(array $invoice) } $taxRate = '-2'; - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { if (isset($invoice['taxest'][$taxRate]) || isset($invoice['invoice']['taxest'][$taxRate])) { if (isset($invoice['taxest'][$taxRate])) { $base = round(($invoice['taxest'][$taxRate]['base'] - $invoice['invoice']['taxest'][$taxRate]['base']), 2); @@ -1018,7 +1023,7 @@ private function buildInvoiceXml(array $invoice) } } - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { $xml .= "\t\t" . sprintf('%.2f', $diffTotal) . "" . PHP_EOL; } else { $xml .= "\t\t" . sprintf('%.2f', $invoice['total']) . "" . PHP_EOL; @@ -1056,7 +1061,7 @@ private function buildInvoiceXml(array $invoice) $xml .= "\t\t\t" . PHP_EOL; $xml .= "\t\t" . PHP_EOL; - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { $xml .= "\t\tKOR" . PHP_EOL; if (!empty($invoice['reason'])) { $xml .= "\t\t" . htmlspecialchars($invoice['reason']) . "" . PHP_EOL; @@ -1348,7 +1353,7 @@ private function buildInvoiceXml(array $invoice) foreach ($invoice['content'] as $position) { $itemId = $position['itemid']; - if ($invoiceType == DOC_CNOTE && !empty($refInvoiceContent[$itemId])) { + if ($invoice['type'] == DOC_CNOTE && !empty($refInvoiceContent[$itemId])) { $description = htmlspecialchars($refInvoiceContent[$itemId]['description']); if (mb_strlen($description) > 512) { $description = mb_substr($description, 0, 512 - strlen(' [...]')) . ' [...]'; @@ -1534,7 +1539,7 @@ private function buildInvoiceXml(array $invoice) } if (!empty($invoice['ksefshowbalancesummary'])) { - if ($invoiceType == DOC_CNOTE) { + if ($invoice['type'] == DOC_CNOTE) { $total = $diffTotal; } else { $total = $invoice['total']; @@ -1579,7 +1584,7 @@ private function buildInvoiceXml(array $invoice) $xml .= "\t\t\t\t" . date('Y-m-d', $invoice['pdate']) . "" . PHP_EOL; /* if ($currency != $this->defaultCurrency) { - $total = $invoiceType == DOC_CNOTE ? $diffTotal : $invoice['total']; + $total = $invoice['type'] == DOC_CNOTE ? $diffTotal : $invoice['total']; if ($total >= 0) { $xml .= "\t\t\t\tDo zapłaty " . moneyf($total * $currencyValue) . ';' . ' cena umowna ' . moneyf($total, $currency) @@ -2131,39 +2136,34 @@ public static function downloadUpoFile($invoiceStatus) public static function saveUpoContent($ksefNumber, $upoContent) { - if (!self::ensureUpoStorageDirectory()) { - return false; + try { + KsefNumber::from($ksefNumber); + } catch (\Throwable $e) { + return 'Invalid KSeF invoice number.'; } 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)) { - mkdir($ksefUpoTenDir); - @chmod( - $ksefUpoTenDir, - fileperms(self::KSEF_UPO_DIR) & 0xfff - ); - @chown($ksefUpoTenDir, fileowner(self::KSEF_UPO_DIR)); - @chgrp($ksefUpoTenDir, filegroup(self::KSEF_UPO_DIR)); + if (!self::ensureUpoStorageDirectory()) { + return false; } - $ksefUpoTenDateDir = $ksefUpoTenDir . DIRECTORY_SEPARATOR . $date; - if (!is_dir($ksefUpoTenDateDir)) { - mkdir($ksefUpoTenDateDir); - @chmod( - $ksefUpoTenDateDir, - fileperms(self::KSEF_UPO_DIR) & 0xfff - ); - @chown($ksefUpoTenDateDir, fileowner(self::KSEF_UPO_DIR)); - @chgrp($ksefUpoTenDateDir, filegroup(self::KSEF_UPO_DIR)); + [$ten, $date] = explode('-', $ksefNumber); + + $upoDirectory = self::KSEF_UPO_DIR; + foreach ([$ten, $date] as $directoryName) { + $upoDirectory .= DIRECTORY_SEPARATOR . $directoryName; + if (!is_dir($upoDirectory)) { + mkdir($upoDirectory); + @chmod($upoDirectory, fileperms(self::KSEF_UPO_DIR) & 0xfff); + @chown($upoDirectory, fileowner(self::KSEF_UPO_DIR)); + @chgrp($upoDirectory, filegroup(self::KSEF_UPO_DIR)); + } } - $upoFile = $ksefUpoTenDateDir . DIRECTORY_SEPARATOR . $ksefNumber . '.xml'; + $upoFile = $upoDirectory . DIRECTORY_SEPARATOR . $ksefNumber . '.xml'; if (file_put_contents($upoFile, $upoContent) !== false) { @chmod( $upoFile, diff --git a/lib/KSeF/KSeFConfig.php b/lib/KSeF/KSeFConfig.php index ad72d39825..06b0b440b1 100644 --- a/lib/KSeF/KSeFConfig.php +++ b/lib/KSeF/KSeFConfig.php @@ -98,28 +98,16 @@ private static function parseEnvironment($environment): int { $environment = strtolower(trim((string) $environment)); - switch ($environment) { - case 'test': - case '1': - return KSeF::ENVIRONMENT_TEST; - case 'prod': - case 'production': - case '2': - return KSeF::ENVIRONMENT_PROD; - case 'demo': - case '3': - return KSeF::ENVIRONMENT_DEMO; - default: - throw new \InvalidArgumentException('Unsupported KSeF environment: ' . $environment); - } + return match ($environment) { + 'test', '1' => KSeF::ENVIRONMENT_TEST, + 'prod', 'production', '2' => KSeF::ENVIRONMENT_PROD, + 'demo', '3' => KSeF::ENVIRONMENT_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; diff --git a/lib/KSeF/KSeFRepository.php b/lib/KSeF/KSeFRepository.php index 8cc26b2d2a..de0fc26879 100644 --- a/lib/KSeF/KSeFRepository.php +++ b/lib/KSeF/KSeFRepository.php @@ -59,13 +59,13 @@ public function getEligibleInvoices( return $this->db->GetAll($query) ?: []; } - public function reserveInvoices(array $documents, int $environment, int $createdAt): array + public function reserveInvoices(array $documents, int $environment): array { if (empty($documents)) { throw new \InvalidArgumentException('KSeF invoice reservation requires at least one document.'); } - $sessionReferenceNumber = $this->localReference('LOCAL-S', (int) $documents[0]['docid']); + $sessionReferenceNumber = 'LOCAL-S-' . (int) $documents[0]['docid'] . '-' . \Utils::randomBytes(12); $this->db->BeginTrans(); try { @@ -116,11 +116,9 @@ public function reserveInvoices(array $documents, int $environment, int $created $this->db->Execute( 'INSERT INTO ksefbatchsessions (ksefnumber, cdate, lastupdate, status, statusdescription, environment) - VALUES (?, ?, ?, ?, ?, ?)', + VALUES (?, ?NOW?, ?NOW?, ?, ?, ?)', [ $sessionReferenceNumber, - $createdAt, - $createdAt, KSeF::STATUS_PENDING, 'Reserved for KSeF submission.', $environment, @@ -300,17 +298,8 @@ public function saveUpo(string $ksefNumber, string $content): void } } - 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)))); + return array_values(array_unique(array_filter(array_map('intval', \Utils::filterIntegers($ids))))); } } diff --git a/lib/KSeF/KSeFRepositoryInterface.php b/lib/KSeF/KSeFRepositoryInterface.php index e9ff67b3bb..c97b8d37fb 100644 --- a/lib/KSeF/KSeFRepositoryInterface.php +++ b/lib/KSeF/KSeFRepositoryInterface.php @@ -11,7 +11,7 @@ public function getEligibleInvoices( ?array $docIds = null ): array; - public function reserveInvoices(array $documents, int $environment, int $createdAt): array; + public function reserveInvoices(array $documents, int $environment): array; public function updateSessionReference(int $id, string $referenceNumber): void; diff --git a/lib/KSeF/KSeFSubmissionService.php b/lib/KSeF/KSeFSubmissionService.php index 520317abf8..7da630fcda 100644 --- a/lib/KSeF/KSeFSubmissionService.php +++ b/lib/KSeF/KSeFSubmissionService.php @@ -46,7 +46,7 @@ public function send( } $invoices = $this->repository->getEligibleInvoices( - $this->getDocumentLimit($config, $docIds), + $docIds === null ? $config->getMaxDocuments() : count($docIds), $divisionId, $customerId, $docIds @@ -102,7 +102,7 @@ public function send( } $invoiceGroups[$groupKey]['invoices'][] = [ - 'invoice' => $invoice, + 'docid' => (int) $invoice['id'], 'xml' => $xml, 'hash' => $this->invoiceHash($xml), ]; @@ -117,7 +117,7 @@ public function send( $documents = []; foreach ($preparedInvoices as $preparedInvoice) { $documents[] = [ - 'docid' => (int) $preparedInvoice['invoice']['id'], + 'docid' => $preparedInvoice['docid'], 'hash' => $preparedInvoice['hash'], ]; } @@ -125,8 +125,7 @@ public function send( try { $reserved = $this->repository->reserveInvoices( $documents, - $groupConfig->getEnvironment(), - time() + $groupConfig->getEnvironment() ); foreach ($reserved['skipped'] as $docId => $error) { @@ -148,7 +147,7 @@ public function send( $xmlDocuments = []; foreach ($preparedInvoices as $preparedInvoice) { - if (isset($reservedDocIds[(int) $preparedInvoice['invoice']['id']])) { + if (isset($reservedDocIds[$preparedInvoice['docid']])) { $xmlDocuments[] = $preparedInvoice['xml']; } } @@ -171,14 +170,7 @@ public function send( $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 - ); + $failedInvoices = !empty($reserved['documents']) ? $reserved['documents'] : $documents; foreach ($failedInvoices as $failedInvoice) { $result['skipped']++; $result['errors'][] = [ @@ -209,7 +201,7 @@ public function sync( } $documents = $this->repository->getPendingDocuments( - $this->getDocumentLimit($config, $docIds), + $docIds === null ? $config->getMaxDocuments() : count($docIds), $divisionId, $customerId, $docIds @@ -245,7 +237,7 @@ public function sync( foreach ($sessionGroups as $sessionGroup) { try { - $invoices = $this->waitForInvoices( + $invoicesByOrdinalNumber = $this->waitForInvoices( $sessionGroup['config'], $sessionGroup['seller_ten'], $sessionGroup['reference_number'], @@ -263,15 +255,15 @@ public function sync( foreach ($sessionGroup['documents'] as $document) { try { - $status = $this->findInvoice($invoices, $document); - if ($status === null) { + $ordinalNumber = (int) $document['ordinalnumber']; + if (!isset($invoicesByOrdinalNumber[$ordinalNumber])) { throw new \RuntimeException( 'Couldn\'t find KSeF invoice for session ' . $document['session_reference_number'] . ' and ordinal number ' . $document['ordinalnumber'] . '.' ); } - $this->updateDocument($document, $status); + $this->updateDocument($document, $invoicesByOrdinalNumber[$ordinalNumber]); $result['updated']++; } catch (\Throwable $e) { $result['errors'][] = [ @@ -305,15 +297,6 @@ private function configForDivision(?int $divisionId, KSeFConfig $defaultConfig): return $config; } - private function getDocumentLimit(KSeFConfig $config, ?array $docIds): int - { - if ($docIds === null) { - return $config->getMaxDocuments(); - } - - return count($docIds); - } - private function invoiceHash(string $xml): string { return base64_encode(hash('sha256', $xml, true)); @@ -326,7 +309,6 @@ private function waitForInvoices( array $documents ): array { $waitedSeconds = 0; - $lastInvoices = []; for ($attempt = 0; $attempt === 0 || $waitedSeconds < self::INVOICE_LIST_WAIT_SECONDS; $attempt++) { if ($attempt > 0) { $sleepSeconds = self::INVOICE_LIST_RETRY_SECONDS[ @@ -337,24 +319,24 @@ private function waitForInvoices( $waitedSeconds += $sleepSeconds; } - $invoices = $this->gateway->listInvoices( - $config, - $sellerTen, - $sessionReferenceNumber - ); - $lastInvoices = $invoices; - if ($this->containsAllDocuments($invoices, $documents)) { - return $invoices; + $invoicesByOrdinalNumber = []; + foreach ($this->gateway->listInvoices($config, $sellerTen, $sessionReferenceNumber) as $invoice) { + if (isset($invoice['ordinal_number'])) { + $invoicesByOrdinalNumber[(int) $invoice['ordinal_number']] = $invoice; + } + } + if ($this->containsAllDocuments($invoicesByOrdinalNumber, $documents)) { + return $invoicesByOrdinalNumber; } } - return $lastInvoices; + return $invoicesByOrdinalNumber; } - private function containsAllDocuments(array $invoices, array $documents): bool + private function containsAllDocuments(array $invoicesByOrdinalNumber, array $documents): bool { foreach ($documents as $document) { - if ($this->findInvoice($invoices, $document) === null) { + if (!isset($invoicesByOrdinalNumber[(int) $document['ordinalnumber']])) { return false; } } @@ -362,24 +344,11 @@ private function containsAllDocuments(array $invoices, array $documents): bool return true; } - private function findInvoice(array $invoices, array $document): ?array - { - foreach ($invoices as $invoice) { - if (isset($invoice['ordinal_number']) - && (int) $invoice['ordinal_number'] === (int) $document['ordinalnumber'] - ) { - return $invoice; - } - } - - return null; - } - private function updateDocument(array $document, array $status): void { $statusCode = (int) ($status['status'] ?? KSeF::STATUS_PENDING); $ksefNumber = $status['ksef_number'] ?? null; - if ($statusCode === 440 && !empty($status['original_ksef_number'])) { + if ($statusCode === KSeF::STATUS_DUPLICATE && !empty($status['original_ksef_number'])) { $statusCode = KSeF::STATUS_ACCEPTED; $ksefNumber = $status['original_ksef_number']; } @@ -417,10 +386,8 @@ private function normalizeStorageDate(?string $date): ?string private function normalizeDocumentIds(?array $docIds): ?array { - if ($docIds === null) { - return null; - } - - return array_values(array_unique(array_filter(array_map('intval', $docIds)))); + return $docIds === null + ? null + : array_values(array_unique(array_filter(array_map('intval', \Utils::filterIntegers($docIds))))); } } diff --git a/lib/KSeF/N1ebieskiKSeFGateway.php b/lib/KSeF/N1ebieskiKSeFGateway.php index 59abb1b230..49299f634e 100644 --- a/lib/KSeF/N1ebieskiKSeFGateway.php +++ b/lib/KSeF/N1ebieskiKSeFGateway.php @@ -2,31 +2,36 @@ namespace Lms\KSeF; +use N1ebieski\KSEFClient\ClientBuilder; +use N1ebieski\KSEFClient\Contracts\Resources\ClientResourceInterface; +use N1ebieski\KSEFClient\Factories\EncryptionKeyFactory; use N1ebieski\KSEFClient\Requests\Sessions\Batch\Close\CloseRequest; use N1ebieski\KSEFClient\Requests\Sessions\Batch\OpenAndSend\OpenAndSendXmlRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\KsefUpo\KsefUpoRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\List\ListRequest; use N1ebieski\KSEFClient\Requests\Sessions\Invoices\Upo\UpoRequest; use N1ebieski\KSEFClient\Support\Optional; +use N1ebieski\KSEFClient\Validator\Rules\Xml\SchemaRule; +use N1ebieski\KSEFClient\Validator\Validator; +use N1ebieski\KSEFClient\ValueObjects\Mode; use N1ebieski\KSEFClient\ValueObjects\Requests\ContinuationToken; use N1ebieski\KSEFClient\ValueObjects\Requests\KsefNumber; use N1ebieski\KSEFClient\ValueObjects\Requests\ReferenceNumber; use N1ebieski\KSEFClient\ValueObjects\Requests\Sessions\FormCode; use N1ebieski\KSEFClient\ValueObjects\Requests\Sessions\PageSize; +use N1ebieski\KSEFClient\ValueObjects\SchemaPath; class N1ebieskiKSeFGateway implements KSeFGatewayInterface { - const INVOICE_LIST_PAGE_SIZE = 1000; + private const INVOICE_LIST_PAGE_SIZE = 1000; private $clients = []; public function validateXml(string $xml): void { try { - \N1ebieski\KSEFClient\Validator\Validator::validate($xml, [ - new \N1ebieski\KSEFClient\Validator\Rules\Xml\SchemaRule( - \N1ebieski\KSEFClient\ValueObjects\SchemaPath::from(FormCode::Fa3->getSchemaPath()) - ), + Validator::validate($xml, [ + new SchemaRule(SchemaPath::from(FormCode::Fa3->getSchemaPath())), ]); } catch (\Throwable $e) { throw new \RuntimeException($this->formatXmlValidationException($e), 0, $e); @@ -101,8 +106,7 @@ public function listInvoices(KSeFConfig $config, string $sellerTen, string $sess ReferenceNumber::from($invoice->referenceNumber) )) ->body(); - } elseif ( - $statusCode === 440 + } elseif ($statusCode === KSeF::STATUS_DUPLICATE && !empty($originalKsefNumber) && !empty($originalSessionReferenceNumber) ) { @@ -140,28 +144,24 @@ public function listInvoices(KSeFConfig $config, string $sellerTen, string $sess return $invoices; } - private function buildClient(KSeFConfig $config, string $sellerTen) + private function buildClient(KSeFConfig $config, string $sellerTen): ClientResourceInterface { - if (!class_exists('\N1ebieski\KSEFClient\ClientBuilder')) { - throw new \RuntimeException('Missing n1ebieski/ksef-php-client dependency. Run composer install.'); - } - $clientKey = spl_object_hash($config) . ':' . $sellerTen; if (isset($this->clients[$clientKey])) { return $this->clients[$clientKey]; } - $builder = (new \N1ebieski\KSEFClient\ClientBuilder()) + $builder = (new ClientBuilder()) ->withMode($this->mode($config)) - ->withEncryptionKey(\N1ebieski\KSEFClient\Factories\EncryptionKeyFactory::makeRandom()) + ->withEncryptionKey(EncryptionKeyFactory::makeRandom()) ->withValidateXml(false); - $builder = $builder->withIdentifier($sellerTen); + $builder->withIdentifier($sellerTen); if ($config->usesApiToken()) { - $builder = $builder->withKsefToken($config->getToken()); + $builder->withKsefToken($config->getToken()); } else { - $builder = $builder->withCertificatePath( + $builder->withCertificatePath( $config->getCertificatePath(), $config->getCertificatePassword() ); @@ -172,20 +172,20 @@ private function buildClient(KSeFConfig $config, string $sellerTen) return $this->clients[$clientKey]; } - private function mode(KSeFConfig $config) + private function mode(KSeFConfig $config): Mode { - 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; - } + return match ($config->getEnvironment()) { + KSeF::ENVIRONMENT_PROD => Mode::Production, + KSeF::ENVIRONMENT_DEMO => Mode::Demo, + default => Mode::Test, + }; } - private function fetchOriginalUpo($client, string $sessionReferenceNumber, string $ksefNumber): ?string - { + private function fetchOriginalUpo( + ClientResourceInterface $client, + string $sessionReferenceNumber, + string $ksefNumber + ): ?string { try { return $client ->sessions() @@ -240,12 +240,12 @@ private function createInvoiceListRequest( ); } - private function extractStatusDetails($response): ?string + private function extractStatusDetails(object $invoice): ?string { - if (!empty($response->status->details)) { - return is_string($response->status->details) - ? $response->status->details - : json_encode($response->status->details); + if (!empty($invoice->status->details)) { + return is_string($invoice->status->details) + ? $invoice->status->details + : json_encode($invoice->status->details); } return null; diff --git a/lib/locale/pl_PL/strings.php b/lib/locale/pl_PL/strings.php index 6b9ddc7c8e..26e0577ef5 100644 --- a/lib/locale/pl_PL/strings.php +++ b/lib/locale/pl_PL/strings.php @@ -6417,9 +6417,6 @@ $_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:'; diff --git a/modules/invoiceksefinfo.php b/modules/invoiceksefinfo.php index dfb94afd7d..0a9801259e 100644 --- a/modules/invoiceksefinfo.php +++ b/modules/invoiceksefinfo.php @@ -24,11 +24,11 @@ * $Id$ */ -use \Lms\KSeF\KSeF; -use \Lms\KSeF\KSeFConfig; -use \Lms\KSeF\KSeFRepository; -use \Lms\KSeF\KSeFSubmissionService; -use \Lms\KSeF\N1ebieskiKSeFGateway; +use Lms\KSeF\KSeF; +use Lms\KSeF\KSeFConfig; +use Lms\KSeF\KSeFRepository; +use Lms\KSeF\KSeFSubmissionService; +use Lms\KSeF\N1ebieskiKSeFGateway; if (!empty($_GET['action']) && $_GET['action'] == 'send-result') { if (!ConfigHelper::checkPrivileges('finances_management', 'financial_operations')) { @@ -70,20 +70,22 @@ if (!empty($_GET['id'])) { $docIds = [ - intval($_GET['id']), + $_GET['id'], ]; } elseif (isset($_POST['marks']) && is_array($_POST['marks'])) { - $docIds = Utils::filterIntegers($_POST['marks']); + $docIds = $_POST['marks']; } else { $docIds = []; } - $docIds = array_values(array_unique(array_filter(array_map('intval', $docIds)))); + $docIds = array_values(array_unique(array_filter(array_map('intval', Utils::filterIntegers($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'])) { + if (!empty($_POST['backurl']) + && is_string($_POST['backurl']) + && preg_match('/^\?m=invoicelist(?:[&#]|$)/', $_POST['backurl']) + ) { $backUrl = $_POST['backurl']; } @@ -97,7 +99,7 @@ ConfigHelper::setFilter($divisionId); } - return KSeFConfig::fromConfigHelper(true); + return KSeFConfig::fromConfigHelper(); }; $config = KSeFConfig::fromConfigHelper(false); $ksef = new KSeF($DB, $LMS); diff --git a/tests/lib/KSeF/FakeKSeFRepository.php b/tests/lib/KSeF/FakeKSeFRepository.php index 36703358a6..46c5bc367b 100644 --- a/tests/lib/KSeF/FakeKSeFRepository.php +++ b/tests/lib/KSeF/FakeKSeFRepository.php @@ -43,7 +43,7 @@ public function getEligibleInvoices( return $this->eligibleInvoices; } - public function reserveInvoices(array $documents, int $environment, int $createdAt): array + public function reserveInvoices(array $documents, int $environment): array { if (!empty($this->reservedSkipped)) { return [ diff --git a/tests/lib/KSeF/KSeFConfigTest.php b/tests/lib/KSeF/KSeFConfigTest.php index ea8534e3e9..7a8373b246 100644 --- a/tests/lib/KSeF/KSeFConfigTest.php +++ b/tests/lib/KSeF/KSeFConfigTest.php @@ -6,10 +6,6 @@ define('STORAGE_DIR', sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'lms-ksef-test-storage'); } -if (!class_exists('PHPUnit\Framework\TestCase') && class_exists('PHPUnit_Framework_TestCase')) { - class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); -} - use Lms\KSeF\KSeF; use Lms\KSeF\KSeFConfig; use PHPUnit\Framework\TestCase; diff --git a/tests/lib/KSeF/KSeFSubmissionServiceTest.php b/tests/lib/KSeF/KSeFSubmissionServiceTest.php index 783be58199..d783439f9d 100644 --- a/tests/lib/KSeF/KSeFSubmissionServiceTest.php +++ b/tests/lib/KSeF/KSeFSubmissionServiceTest.php @@ -6,10 +6,6 @@ define('STORAGE_DIR', sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'lms-ksef-test-storage'); } -if (!class_exists('PHPUnit\Framework\TestCase') && class_exists('PHPUnit_Framework_TestCase')) { - class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); -} - require_once __DIR__ . '/FakeKSeFGateway.php'; require_once __DIR__ . '/FakeKSeFRepository.php'; @@ -330,7 +326,7 @@ public function testSyncRecoversOriginalNumberAndUpoForDuplicate() $repository = new FakeKSeFRepository([], [$this->pendingDocument()]); $gateway = new FakeKSeFGateway(); $gateway->sessionInvoices['SESSION-1'] = [$this->remoteInvoice(1, [ - 'status' => 440, + 'status' => KSeF::STATUS_DUPLICATE, 'status_description' => 'Duplikat faktury', 'status_details' => 'Duplikat faktury.', 'original_ksef_number' => '1234567890-20260424-ABCDEF', diff --git a/tests/lib/KSeF/KSeFTest.php b/tests/lib/KSeF/KSeFTest.php index fdf39c3da1..7d66f41661 100644 --- a/tests/lib/KSeF/KSeFTest.php +++ b/tests/lib/KSeF/KSeFTest.php @@ -26,9 +26,6 @@ define('PAYTYPE_TRANSFER', 2); } - if (!class_exists('PHPUnit\Framework\TestCase') && class_exists('PHPUnit_Framework_TestCase')) { - class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); - } require_once __DIR__ . '/../../../lib/LMS.class.php'; require_once __DIR__ . '/../../../lib/Utils.php'; } @@ -88,7 +85,7 @@ public function testSaveUpoContentCreatesMissingStorageDirectory() $this->resetKSeFUpoStorageCache(); try { - $ksefNumber = '1234567890-20260425-ABCDEF'; + $ksefNumber = '1234567890-20260425-ABCDEF123456-AB'; $this->assertFalse(KSeF::upoFileExists($ksefNumber)); $result = KSeF::saveUpoContent($ksefNumber, 'test'); @@ -107,6 +104,24 @@ public function testSaveUpoContentCreatesMissingStorageDirectory() } } + public function testSaveUpoContentRejectsInvalidKSeFNumber() + { + $storageDir = STORAGE_DIR . DIRECTORY_SEPARATOR . 'ksef'; + $this->removeDirectory($storageDir); + $this->resetKSeFUpoStorageCache(); + + try { + $this->assertSame( + 'Invalid KSeF invoice number.', + KSeF::saveUpoContent('../outside', 'test') + ); + $this->assertDirectoryDoesNotExist($storageDir); + } finally { + $this->removeDirectory($storageDir); + $this->resetKSeFUpoStorageCache(); + } + } + public function testFormatStatusDetailsDecodesJsonUnicodeEscapes() { $this->assertSame( diff --git a/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php index 1e61265042..f3092c18ad 100644 --- a/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php +++ b/tests/lib/KSeF/N1ebieskiKSeFGatewayTest.php @@ -2,10 +2,6 @@ namespace LMS\Tests\KSeF; -if (!class_exists('PHPUnit\Framework\TestCase') && class_exists('PHPUnit_Framework_TestCase')) { - class_alias('PHPUnit_Framework_TestCase', 'PHPUnit\Framework\TestCase'); -} - use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; From 4c6d8dc8bd6b1e7aa5c2554fe79e1c51541742cb Mon Sep 17 00:00:00 2001 From: Konrad Cempura Date: Mon, 27 Jul 2026 16:17:26 +0200 Subject: [PATCH 17/17] refactor: centralize KSeF submission errors --- lib/KSeF/KSeFSubmissionService.php | 68 ++++++++++++------------------ 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/lib/KSeF/KSeFSubmissionService.php b/lib/KSeF/KSeFSubmissionService.php index 7da630fcda..c217995d78 100644 --- a/lib/KSeF/KSeFSubmissionService.php +++ b/lib/KSeF/KSeFSubmissionService.php @@ -55,39 +55,23 @@ public function send( 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'], - ]; + $this->skipInvoice($result, (int) $invoice['id'], $xml['error']); continue; } if (!is_string($xml) || trim($xml) === '') { - $result['skipped']++; - $result['errors'][] = [ - 'docid' => (int) $invoice['id'], - 'error' => 'Empty KSeF XML document.', - ]; + $this->skipInvoice($result, (int) $invoice['id'], 'Empty KSeF XML document.'); continue; } try { $this->gateway->validateXml($xml); } catch (\Throwable $e) { - $result['skipped']++; - $result['errors'][] = [ - 'docid' => (int) $invoice['id'], - 'error' => $e->getMessage(), - ]; + $this->skipInvoice($result, (int) $invoice['id'], $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.', - ]; + $this->skipInvoice($result, (int) $invoice['id'], 'Missing seller TEN.'); continue; } @@ -129,11 +113,7 @@ public function send( ); foreach ($reserved['skipped'] as $docId => $error) { - $result['skipped']++; - $result['errors'][] = [ - 'docid' => (int) $docId, - 'error' => $error, - ]; + $this->skipInvoice($result, (int) $docId, $error); } if (empty($reserved['documents'])) { @@ -172,11 +152,7 @@ public function send( $failedInvoices = !empty($reserved['documents']) ? $reserved['documents'] : $documents; foreach ($failedInvoices as $failedInvoice) { - $result['skipped']++; - $result['errors'][] = [ - 'docid' => (int) $failedInvoice['docid'], - 'error' => $e->getMessage(), - ]; + $this->skipInvoice($result, (int) $failedInvoice['docid'], $e->getMessage()); } } } @@ -228,10 +204,7 @@ public function sync( } $sessionGroups[$groupKey]['documents'][] = $document; } catch (\Throwable $e) { - $result['errors'][] = [ - 'id' => (int) $document['id'], - 'error' => $e->getMessage(), - ]; + $this->addSyncError($result, (int) $document['id'], $e->getMessage()); } } @@ -245,10 +218,7 @@ public function sync( ); } catch (\Throwable $e) { foreach ($sessionGroup['documents'] as $document) { - $result['errors'][] = [ - 'id' => (int) $document['id'], - 'error' => $e->getMessage(), - ]; + $this->addSyncError($result, (int) $document['id'], $e->getMessage()); } continue; } @@ -266,10 +236,7 @@ public function sync( $this->updateDocument($document, $invoicesByOrdinalNumber[$ordinalNumber]); $result['updated']++; } catch (\Throwable $e) { - $result['errors'][] = [ - 'id' => (int) $document['id'], - 'error' => $e->getMessage(), - ]; + $this->addSyncError($result, (int) $document['id'], $e->getMessage()); } } } @@ -277,6 +244,23 @@ public function sync( return $result; } + private function skipInvoice(array &$result, int $docId, $error): void + { + $result['skipped']++; + $result['errors'][] = [ + 'docid' => $docId, + 'error' => $error, + ]; + } + + private function addSyncError(array &$result, int $documentId, $error): void + { + $result['errors'][] = [ + 'id' => $documentId, + 'error' => $error, + ]; + } + private function configForDivision(?int $divisionId, KSeFConfig $defaultConfig): KSeFConfig { if ($this->configProvider === null || $divisionId === null) {