*/ function r3Input(Request $request): array { return [ 'householdYearId' => $request->input('householdYearId', 0), 'collector' => $request->input('collector', ''), 'donation' => $request->input('donation', 0), 'place' => $request->input('place', ''), 'date' => $request->input('date', ''), ]; } function r3RequestId(string $value): string { $value = trim($value); if (!preg_match('/^PHASE7C-A6-R2-R3-[a-f0-9-]{36}$/D', $value)) { throw new ApiException( 'R3_REQUEST_ID_INVALID', 'Preview requestId nije ispravan. Napravite novi preview.', 400 ); } return $value; } /** @param array $plan */ function r3BindPreviewIdentity(array &$plan, Request $request): void { $requestId = r3RequestId((string) $request->input('previewRequestId', '')); $expectedFingerprint = trim((string) $request->input('previewBusinessFingerprint', '')); $expectedRowVersion = (int) $request->input('previewRowVersion', -1); $expectedHouseholdYearId = (int) $request->input('previewHouseholdYearId', 0); $checks = [ 'businessFingerprintUnchanged' => $expectedFingerprint !== '' && hash_equals( $expectedFingerprint, (string) ($plan['beforeBusinessFingerprint'] ?? '') ), 'rowVersionUnchanged' => $expectedRowVersion >= 0 && $expectedRowVersion === (int) ($plan['candidateRowVersion'] ?? -2), 'candidateUnchanged' => $expectedHouseholdYearId > 0 && $expectedHouseholdYearId === (int) ($plan['candidate']['householdYearId'] ?? 0), 'receiptStill105' => (int) ($plan['expectedReceiptNumber'] ?? 0) === 105, ]; if (in_array(false, $checks, true)) { throw new ApiException( 'R3_PREVIEW_STATE_CHANGED', 'Stanje više nije isto kao u preview-u. Osvežite spisak i napravite novi preview.', 409, ['checks' => $checks] ); } $plan['requestId'] = $requestId; $plan['payment']['requestId'] = $requestId; $plan['commitProtocol'] = R3_COMMIT_PROTOCOL; $plan['endpointPatchVersion'] = R3_ENDPOINT_PATCH_VERSION; } $router->post('login', static fn(Request $r): array => (new AuthService())->login($r)); $router->post('logout', static fn(Request $r): array => (new AuthService())->logout($r)); $router->get('me', static fn(Request $r): array => (new AuthService())->me($r)); $router->post('me', static fn(Request $r): array => (new AuthService())->me($r)); $requireOwner = static function (Request $r) use ($authService): array { $session = $authService->requireUser($r); if (($session['user']['permissions']['canManageBackend'] ?? false) !== true) { throw new ApiException( 'FORBIDDEN', 'Samo vlasnik sistema može pokrenuti prvu kontrolisanu transakciju.', 403 ); } return $session; }; $health = static fn(Request $r): array => [ 'application' => 'Seoski odbor Gornje Rapče', 'service' => 'SOGR First Transaction Canary API', 'phase' => '7C-A6-R2-R3', 'version' => R3_ENDPOINT_PATCH_VERSION, 'serviceVersion' => Phase7CA6R2R3FirstTransactionCanaryService::VERSION, 'mode' => 'CONTROLLED_FIRST_REAL_TRANSACTION_CANARY', 'commitProtocol' => R3_COMMIT_PROTOCOL, 'fileSigningRequired' => false, 'runtimeCompatibility' => [ 'mbstringAvailable' => function_exists('mb_substr'), 'iconvAvailable' => function_exists('iconv_substr'), 'utf8SubstrFallbackInstalled' => true, ], 'status' => 'available', ]; $router->get('healthCheck', $health); $router->post('healthCheck', $health); $status = static function (Request $r) use ($requireOwner, $service): array { $requireOwner($r); $result = $service->status(); $result['endpointPatchVersion'] = R3_ENDPOINT_PATCH_VERSION; $result['commitProtocol'] = R3_COMMIT_PROTOCOL; $result['fileSigningRequired'] = false; return $result; }; $router->get('phase7cA6R2R3Status', $status); $router->post('phase7cA6R2R3Status', $status); $prepare = static function (Request $r) use ($requireOwner, $service): array { $session = $requireOwner($r); $plan = $service->preparePlan(r3Input($r), $session); $plan['commitProtocol'] = R3_COMMIT_PROTOCOL; $plan['endpointPatchVersion'] = R3_ENDPOINT_PATCH_VERSION; return [ 'phase' => '7C-A6-R2-R3', 'version' => R3_ENDPOINT_PATCH_VERSION, 'serviceVersion' => Phase7CA6R2R3FirstTransactionCanaryService::VERSION, 'mode' => 'FIRST_REAL_PAYMENT_PREVIEW', 'valid' => true, 'writesPerformed' => false, 'plan' => $plan, 'confirmationRequired' => Phase7CA6R2R3FirstTransactionCanaryService::CONFIRMATION, 'commitProtocol' => R3_COMMIT_PROTOCOL, 'fileSigningRequired' => false, 'nextStep' => 'REVIEW_REAL_PAYER_AND_CONFIRM_00105', ]; }; $router->post('phase7cA6R2R3Prepare', $prepare); $commit = static function (Request $r) use ($requireOwner, $service, $privateRoot): array { $session = $requireOwner($r); $confirmation = trim((string) $r->input('confirmation', '')); if (!hash_equals(Phase7CA6R2R3FirstTransactionCanaryService::CONFIRMATION, $confirmation)) { throw new ApiException( 'CONFIRMATION_INVALID', 'Potvrda za uplatu 00105 nije tačna.', 400 ); } if ($r->input('realPaymentConfirmed', false) !== true) { throw new ApiException( 'REAL_PAYMENT_CONFIRMATION_REQUIRED', 'Morate potvrditi da je izabrana osoba stvarno platila.', 400 ); } try { // Poslovni plan se ponovo izračunava na serveru u trenutku COMMIT-a. $plan = $service->preparePlan(r3Input($r), $session); r3BindPreviewIdentity($plan, $r); $result = $service->commit($plan, $session); $result['endpointPatchVersion'] = R3_ENDPOINT_PATCH_VERSION; $result['commitProtocol'] = R3_COMMIT_PROTOCOL; $result['fileSigningRequired'] = false; $result['runtimeCompatibility'] = [ 'mbstringAvailable' => function_exists('mb_substr'), 'iconvAvailable' => function_exists('iconv_substr'), 'utf8SubstrFallbackInstalled' => true, ]; return $result; } catch (ApiException $e) { throw $e; } catch (\Throwable $e) { $requestId = trim((string) $r->input('previewRequestId', '')); $logDir = $privateRoot . '/logs'; if (!is_dir($logDir)) { @mkdir($logDir, 0700, true); } $logName = 'r3-commit-runtime-' . gmdate('Ymd-His') . '-' . substr(hash('sha256', $requestId . random_bytes(16)), 0, 12) . '.json'; $logPath = $logDir . '/' . $logName; $diagnostic = [ 'phase' => '7C-A6-R2-R3-R4', 'endpointPatchVersion' => R3_ENDPOINT_PATCH_VERSION, 'requestId' => $requestId, 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage(), 'exceptionFile' => basename($e->getFile()), 'exceptionLine' => $e->getLine(), 'mbstringAvailable' => function_exists('mb_substr'), 'iconvAvailable' => function_exists('iconv_substr'), 'utf8SubstrFallbackInstalled' => true, 'capturedAtUtc' => gmdate('c'), ]; @file_put_contents( $logPath, json_encode($diagnostic, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), LOCK_EX ); @chmod($logPath, 0600); throw new ApiException( 'R3_COMMIT_RUNTIME_ERROR', 'COMMIT je zaustavljen serverskom PHP greškom. Automatska provera će utvrditi da li je 00105 nastala.', 500, [ 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage(), 'exceptionFile' => basename($e->getFile()), 'exceptionLine' => $e->getLine(), 'mbstringAvailable' => function_exists('mb_substr'), 'iconvAvailable' => function_exists('iconv_substr'), 'utf8SubstrFallbackInstalled' => true, 'privateLogFile' => $logName, ], $e ); } }; $router->post('phase7cA6R2R3Commit', $commit); $verify = static function (Request $r) use ($requireOwner, $service): array { $requireOwner($r); $requestId = r3RequestId((string) $r->input('requestId', '')); $result = $service->verifyExisting($requestId); $result['endpointPatchVersion'] = R3_ENDPOINT_PATCH_VERSION; $result['commitProtocol'] = R3_COMMIT_PROTOCOL; return $result; }; $router->post('phase7cA6R2R3Verify', $verify); $void = static function (Request $r) use ($requireOwner, $service): array { $session = $requireOwner($r); $confirmation = trim((string) $r->input('confirmation', '')); if (!hash_equals(Phase7CA6R2R3FirstTransactionCanaryService::ROLLBACK_CONFIRMATION, $confirmation)) { throw new ApiException( 'VOID_CONFIRMATION_INVALID', 'Potvrda za emergency void nije tačna.', 400 ); } $requestId = r3RequestId((string) $r->input('requestId', '')); $existing = $service->verifyExisting($requestId); $payment = is_array($existing['payment'] ?? null) ? $existing['payment'] : []; if (($payment['present'] ?? false) !== true) { throw new ApiException( 'R3_CANARY_NOT_FOUND', 'Uplata 00105 nije pronađena. Poništavanje nije dozvoljeno.', 404, ['verification' => $existing] ); } $plan = [ 'phase' => Phase7CA6R2R3FirstTransactionCanaryService::PHASE, 'version' => Phase7CA6R2R3FirstTransactionCanaryService::VERSION, 'expectedReceiptNumber' => 105, 'userId' => (int) ($session['user']['id'] ?? 0), 'requestId' => $requestId, 'candidate' => [ 'householdYearId' => (int) ($payment['householdYearId'] ?? 0), 'row' => (int) ($payment['sourceRow'] ?? 0), 'ime' => (string) ($payment['ime'] ?? ''), 'prezime' => (string) ($payment['prezime'] ?? ''), ], ]; $result = $service->emergencyVoid($plan, $session); $result['endpointPatchVersion'] = R3_ENDPOINT_PATCH_VERSION; $result['commitProtocol'] = R3_COMMIT_PROTOCOL; return $result; }; $router->post('phase7cA6R2R3EmergencyVoid', $void); $diagnostic = static function (Request $r) use ($requireOwner, $service): array { $requireOwner($r); $status = $service->status(); return [ 'phase' => '7C-A6-R2-R3', 'version' => R3_ENDPOINT_PATCH_VERSION, 'mode' => 'R3_COMMIT_PROTOCOL_DIAGNOSTIC', 'valid' => ($status['valid'] ?? false) === true, 'commitProtocol' => R3_COMMIT_PROTOCOL, 'fileSigningRequired' => false, 'legacySigningKeyReadAttempted' => false, 'runtimeCompatibility' => [ 'mbstringAvailable' => function_exists('mb_substr'), 'iconvAvailable' => function_exists('iconv_substr'), 'utf8SubstrFallbackInstalled' => true, ], 'expectedReceiptNumber' => $status['expectedReceiptNumber'] ?? '00105', 'gate' => $status['gate'] ?? null, 'checkedAtUtc' => gmdate('c'), ]; }; $router->post('phase7cA6R2R3Diagnostic', $diagnostic); $router->dispatch($request);