pdo = $pdo ?? Database::connection(); $this->documentRoot = $documentRoot !== null ? rtrim($documentRoot, '/\\') : rtrim((string) ($_SERVER['DOCUMENT_ROOT'] ?? dirname(__DIR__, 3)), '/\\'); $this->privateRoot = $privateRoot !== null ? rtrim($privateRoot, '/\\') : dirname($this->documentRoot) . '/sogr_private'; $this->readService = new Phase7BReadService($this->pdo); $this->syncService = new Phase7CSyncService($this->pdo, $this->privateRoot); $this->writeService = new Phase7CWriteService($this->pdo, $this->syncService); } /** @return array */ public function status(): array { $gate = $this->gate(); $candidates = ($gate['valid'] ?? false) === true ? $this->candidates() : []; $collectors = ($gate['valid'] ?? false) === true ? $this->collectors() : []; return [ 'phase' => self::PHASE, 'version' => self::VERSION, 'mode' => self::MODE, 'valid' => ($gate['valid'] ?? false) === true && count($candidates) > 0 && count($collectors) > 0, 'ready' => ($gate['valid'] ?? false) === true && count($candidates) > 0 && count($collectors) > 0, 'writesPerformed' => false, 'mysqlBusinessWrites' => 0, 'googleSheetsCalls' => 1, 'googleSheetsWrites' => 0, 'runtimeCompatibility' => [ 'mbstringAvailable' => function_exists('mb_substr'), 'iconvAvailable' => function_exists('iconv_substr'), 'utf8SubstrFallbackInstalled' => true, 'pdoDuplicateNamedPlaceholderRepairInstalled' => true, ], 'expectedReceiptNumber' => '00105', 'gate' => $gate, 'candidateCount' => count($candidates), 'candidates' => $candidates, 'collectors' => $collectors, 'confirmationRequired' => self::CONFIRMATION, 'importantNote' => 'Izaberite samo osobu koja stvarno plaća. Uspešna priznanica 00105 ostaje važeća.', 'nextStep' => ($gate['valid'] ?? false) === true ? 'SELECT_REAL_PAYER_AND_PREPARE_00105' : 'STOP_AND_REVIEW_R3_GATE', 'checkedAtUtc' => gmdate('c'), ]; } /** @param array $input @param array $session @return array */ public function preparePlan(array $input, array $session): array { $gate = $this->gate(); if (($gate['valid'] ?? false) !== true) { throw new ApiException('R3_GATE_NOT_READY', 'Početna kapija nije potpuno čista. Plan nije kreiran.', 409, ['gate'=>$gate]); } $candidate = $this->candidateByInput($input, true); $collector = $this->collector((string) ($input['collector'] ?? '')); $donation = $this->money($input['donation'] ?? 0, 0.0, 10000.0); $place = trim((string) ($input['place'] ?? '')); if ($place === '' || mb_strlen($place) > 100) { throw new ApiException('PLACE_INVALID', 'Mesto uplate je obavezno i može imati najviše 100 znakova.', 400); } $date = trim((string) ($input['date'] ?? '')); $today = (new DateTimeImmutable('now', new DateTimeZone('Europe/Belgrade')))->format('Y-m-d'); if ($date !== $today) { throw new ApiException('CANARY_DATE_MUST_BE_TODAY', 'Prva kontrolisana uplata mora imati današnji datum: ' . $today . '.', 400); } $userId = (int) ($session['user']['id'] ?? 0); if ($userId <= 0) throw new ApiException('SESSION_USER_INVALID', 'Korisnička sesija nije ispravna.', 403); $requestId = 'PHASE7C-A6-R2-R3-' . $this->uuid(); return [ 'phase' => self::PHASE, 'version' => self::VERSION, 'mode' => 'FIRST_REAL_PAYMENT_PREVIEW', 'valid' => true, 'writesPerformed' => false, 'mysqlBusinessWrites' => 0, 'googleSheetsWrites' => 0, 'userId' => $userId, 'requestId' => $requestId, 'expectedReceiptNumber' => self::EXPECTED_CANARY_RECEIPT, 'candidate' => $candidate, 'payment' => [ 'year' => 2026, 'row' => (int) $candidate['row'], 'ime' => (string) $candidate['ime'], 'prezime' => (string) $candidate['prezime'], 'iznos' => (float) $candidate['dug'], 'donacija' => $donation, 'mesto' => $place, 'odbor' => (string) $collector['code'], 'primioPuno' => (string) $collector['displayName'], 'datum' => $date, 'requestId' => $requestId, ], 'collector' => $collector, 'beforeBusinessFingerprint' => (string) ($gate['database']['businessFingerprint'] ?? ''), 'beforeQueueFingerprint' => (string) ($gate['database']['queueFingerprint'] ?? ''), 'candidateRowVersion' => (int) $candidate['rowVersion'], 'confirmationRequired' => self::CONFIRMATION, 'rollbackConfirmation' => self::ROLLBACK_CONFIRMATION, 'preparedAtUtc' => gmdate('c'), ]; } /** @param array $plan @param array $session @return array */ public function commit(array $plan, array $session): array { $this->validatePlanOwner($plan, $session); $lockName = 'sogr_phase7c_a6_r2_r3_receipt_00105'; $lockStmt = $this->pdo->prepare('SELECT GET_LOCK(:name,10)'); $lockStmt->execute([':name'=>$lockName]); if ((int) $lockStmt->fetchColumn() !== 1) throw new ApiException('R3_LOCK_BUSY', 'Druga kontrola je već u toku.', 409); try { $before = $this->gate(); if (($before['valid'] ?? false) !== true) throw new ApiException('R3_BEFORE_GATE_CHANGED', 'Stanje se promenilo posle preview-a.', 409, ['before'=>$before]); if (!hash_equals((string) ($plan['beforeBusinessFingerprint'] ?? ''), (string) ($before['database']['businessFingerprint'] ?? ''))) { throw new ApiException('R3_BUSINESS_STATE_CHANGED', 'Poslovno stanje se promenilo posle preview-a. Ponovite plan.', 409); } $candidate = $this->candidateByInput(['householdYearId'=>$plan['candidate']['householdYearId'] ?? 0], true); $frozen = (array) ($plan['candidate'] ?? []); $candidateChecks = [ 'sameHouseholdYear' => (int) ($candidate['householdYearId'] ?? 0) === (int) ($frozen['householdYearId'] ?? 0), 'sameRow' => (int) ($candidate['row'] ?? 0) === (int) ($frozen['row'] ?? 0), 'sameRowVersion' => (int) ($candidate['rowVersion'] ?? 0) === (int) ($plan['candidateRowVersion'] ?? -1), 'sameName' => (string) ($candidate['ime'] ?? '') === (string) ($frozen['ime'] ?? '') && (string) ($candidate['prezime'] ?? '') === (string) ($frozen['prezime'] ?? ''), 'sameDue' => abs((float) ($candidate['dug'] ?? -1) - (float) ($frozen['dug'] ?? -2)) < 0.001, 'stillUnpaid' => ($candidate['paid'] ?? true) === false, ]; if (in_array(false, $candidateChecks, true)) throw new ApiException('R3_CANDIDATE_CHANGED', 'Izabrani član je promenjen ili je u međuvremenu plaćen.', 409, ['checks'=>$candidateChecks]); $writeResponse = $this->writeService->execute('pay', (array) ($plan['payment'] ?? []), $session); $candidate['expectedDonation'] = (float) ($plan['payment']['donacija'] ?? 0); $candidate['expectedPlace'] = (string) ($plan['payment']['mesto'] ?? ''); $candidate['expectedDate'] = (string) ($plan['payment']['datum'] ?? ''); $candidate['expectedCollectorId'] = (int) ($plan['collector']['id'] ?? 0); $verification = $this->verifyCanary((string) $plan['requestId'], self::EXPECTED_CANARY_RECEIPT, $candidate, true); $valid = ($verification['valid'] ?? false) === true && (($writeResponse['sync']['status'] ?? '') === 'DONE') && (($writeResponse['sync']['sheetsSynced'] ?? false) === true); $result = [ 'phase' => self::PHASE, 'version' => self::VERSION, 'mode' => self::MODE, 'valid' => $valid, 'canaryCommitted' => true, 'writesPerformed' => true, 'productionFilesChanged' => false, 'mysqlPrimaryWritePerformed' => true, 'googleSheetsSyncAttempted' => true, 'expectedReceiptNumber' => '00105', 'requestId' => (string) $plan['requestId'], 'candidate' => $candidate, 'writeResponse' => $writeResponse, 'verification' => $verification, 'recoveryAvailable' => !$valid, 'rollbackConfirmation' => self::ROLLBACK_CONFIRMATION, 'readyForNextPhase' => $valid, 'nextStep' => $valid ? 'READY_FOR_7C_A6_R2_R4_RECEIPT_PDF_PRINT_AND_NORMAL_APP_VALIDATION' : 'STOP_AND_USE_R3_EMERGENCY_VOID_ONLY_AFTER_REVIEW', 'importantNote' => $valid ? 'Prva stvarna uplata 00105 je potvrđena u MySQL-u i Google Sheets-u i ostaje važeća.' : 'MySQL je primarni izvor. Ne ponavljajte uplatu. Sačuvajte rezultat i koristite samo kontrolisani emergency void.', 'checkedAtUtc' => gmdate('c'), ]; $this->writeEvidence($result, (string) $plan['requestId']); return $result; } finally { try { $release=$this->pdo->prepare('SELECT RELEASE_LOCK(:name)'); $release->execute([':name'=>$lockName]); } catch (Throwable) {} } } /** @param array $plan @param array $session @return array */ public function emergencyVoid(array $plan, array $session): array { $this->validatePlanOwner($plan, $session); $requestId = (string) ($plan['requestId'] ?? ''); $candidate = (array) ($plan['candidate'] ?? []); $present = $this->paymentByRequest($requestId); if (($present['present'] ?? false) !== true || (int) ($present['receiptNumber'] ?? 0) !== 105) { throw new ApiException('R3_CANARY_NOT_FOUND', 'Aktivna canary uplata 00105 nije pronađena.', 404); } if (($present['paymentStatus'] ?? '') === 'VOID') return ['valid'=>true,'alreadyVoided'=>true,'payment'=>$present]; $voidPayload = [ 'year'=>2026, 'row'=>(int) ($candidate['row'] ?? 0), 'ime'=>(string) ($candidate['ime'] ?? ''), 'prezime'=>(string) ($candidate['prezime'] ?? ''), 'requestId'=>substr($requestId . '-VOID', 0, 100), ]; $response = $this->writeService->execute('storno', $voidPayload, $session); $after = $this->verifyVoid($requestId, $candidate, $voidPayload['requestId']); return [ 'phase'=>self::PHASE,'version'=>self::VERSION,'mode'=>'EMERGENCY_CANARY_VOID', 'valid'=>($after['valid']??false)===true,'writesPerformed'=>true,'receiptNumber'=>'00105', 'originalRequestId'=>$requestId,'voidRequestId'=>$voidPayload['requestId'], 'writeResponse'=>$response,'verification'=>$after, 'nextStep'=>($after['valid']??false)===true?'READY_FOR_R3_FAILURE_REVIEW_WITH_00105_VOID':'STOP_ALL_WRITES_AND_REVIEW_SYNC_STATE', 'importantNote'=>'Broj 00105 ostaje istorijski zauzet kao VOID i nikada se ne koristi ponovo.', 'checkedAtUtc'=>gmdate('c'), ]; } /** @return array */ public function verifyExisting(string $requestId): array { $payment = $this->paymentByRequest($requestId); if (($payment['present'] ?? false) !== true) return ['valid'=>false,'present'=>false,'requestId'=>$requestId]; $candidate = ['householdYearId'=>(int)($payment['householdYearId']??0),'row'=>(int)($payment['sourceRow']??0),'ime'=>(string)($payment['ime']??''),'prezime'=>(string)($payment['prezime']??''),'dug'=>(float)($payment['membershipTotal']??0)]; return $this->verifyCanary($requestId, 105, $candidate, true); } /** @return array */ private function gate(): array { $files = $this->fileGate(); $settings = $this->settingsGate(); $database = $this->databaseState(); $sync = $this->syncService->status(true); $write = $this->writeService->status(); $evidence = $this->r2Evidence(); $bridge = is_array($sync['bridge'] ?? null) ? $sync['bridge'] : []; $checks = [ 'filesExact' => ($files['valid'] ?? false) === true, 'settingsActive' => ($settings['valid'] ?? false) === true, 'databaseReadyFor105' => ($database['validBefore'] ?? false) === true, 'writeServiceReady' => ($write['ready'] ?? false) === true && (int)($write['actionCount']??0)===10, 'syncQueueClean' => (int)($database['values']['pendingOrProblemSync']??-1)===0, 'bridgeReadyFor105' => ($bridge['ok']??false)===true && ($bridge['ready']??false)===true && (int)($bridge['maxReceiptNumber']??-1)===104 && (int)($bridge['nextReceiptNumber']??-1)===105, 'r2EvidenceValid' => ($evidence['valid'] ?? false) === true, ]; return ['valid'=>!in_array(false,$checks,true),'checks'=>$checks,'files'=>$files,'settings'=>$settings,'database'=>$database,'write'=>$write,'sync'=>$sync,'r2Evidence'=>$evidence,'checkedAtUtc'=>gmdate('c')]; } /** @return list> */ private function candidates(): array { $sql = "SELECT hy.id,hy.rb,hy.first_name,hy.last_name,m.display_name AS mahalla,hy.members_count,hy.due_amount,hy.row_version,srl.sheet_row FROM household_years hy JOIN households h ON h.id=hy.household_id AND h.is_active=1 JOIN mahallas m ON m.id=hy.mahalla_id JOIN google_sheet_documents gsd ON gsd.year=hy.year AND gsd.is_primary=1 AND gsd.is_active=1 JOIN google_sheet_tabs gst ON gst.document_id=gsd.id AND gst.tab_role='ANNUAL' AND gst.is_active=1 JOIN sheet_row_links srl ON srl.sheet_tab_id=gst.id AND srl.entity_type='HOUSEHOLD_YEAR' AND srl.entity_id=hy.id LEFT JOIN active_payment_links apl ON apl.household_year_id=hy.id WHERE hy.year=2026 AND hy.record_status='ACTIVE' AND hy.due_amount>0 AND apl.household_year_id IS NULL ORDER BY m.sort_order,hy.last_name,hy.first_name,srl.sheet_row"; $rows=[]; foreach($this->pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC) as $r) $rows[]=$this->mapCandidate($r); return $rows; } /** @param array $input @return array */ private function candidateByInput(array $input, bool $requireUnpaid): array { $id=(int)($input['householdYearId']??0); $row=(int)($input['row']??0); if($id<=0 && $row<2) throw new ApiException('CANDIDATE_REQUIRED','Izaberite stvarnog neplaćenog člana.',400); $sql="SELECT hy.id,hy.rb,hy.first_name,hy.last_name,m.display_name AS mahalla,hy.members_count,hy.due_amount,hy.row_version,srl.sheet_row,CASE WHEN apl.household_year_id IS NULL THEN 0 ELSE 1 END AS paid FROM household_years hy JOIN households h ON h.id=hy.household_id AND h.is_active=1 JOIN mahallas m ON m.id=hy.mahalla_id JOIN google_sheet_documents gsd ON gsd.year=hy.year AND gsd.is_primary=1 AND gsd.is_active=1 JOIN google_sheet_tabs gst ON gst.document_id=gsd.id AND gst.tab_role='ANNUAL' AND gst.is_active=1 JOIN sheet_row_links srl ON srl.sheet_tab_id=gst.id AND srl.entity_type='HOUSEHOLD_YEAR' AND srl.entity_id=hy.id LEFT JOIN active_payment_links apl ON apl.household_year_id=hy.id WHERE hy.year=2026 AND hy.record_status='ACTIVE' AND ".($id>0?'hy.id=:value':'srl.sheet_row=:value')." LIMIT 1"; $st=$this->pdo->prepare($sql);$st->execute([':value'=>$id>0?$id:$row]);$r=$st->fetch(PDO::FETCH_ASSOC); if(!is_array($r)) throw new ApiException('CANDIDATE_NOT_FOUND','Izabrani član nije pronađen.',404); $mapped=$this->mapCandidate($r);$mapped['paid']=(int)($r['paid']??0)===1; if($requireUnpaid && $mapped['paid']) throw new ApiException('CANDIDATE_ALREADY_PAID','Izabrani član je već plaćen. Osvežite spisak.',409); if((float)$mapped['dug']<=0) throw new ApiException('CANDIDATE_DUE_INVALID','Izabrani red nema pozitivan dug.',409); return $mapped; } /** @param array $r @return array */ private function mapCandidate(array $r): array { return ['householdYearId'=>(int)$r['id'],'row'=>(int)$r['sheet_row'],'rb'=>(string)($r['rb']??''),'ime'=>(string)$r['first_name'],'prezime'=>(string)$r['last_name'],'mahalo'=>(string)$r['mahalla'],'clanova'=>(int)$r['members_count'],'dug'=>round((float)$r['due_amount'],2),'rowVersion'=>(int)$r['row_version'],'paid'=>false,'label'=>trim((string)$r['first_name'].' '.(string)$r['last_name']).' · '.(string)$r['mahalla'].' · '.number_format((float)$r['due_amount'],2,',','').' €']; } /** @return list> */ private function collectors(): array { $rows=[];foreach($this->pdo->query("SELECT id,code,display_name FROM board_members WHERE is_active=1 AND handles_money=1 ORDER BY sort_order,id")->fetchAll(PDO::FETCH_ASSOC) as $r)$rows[]=['id'=>(int)$r['id'],'code'=>(string)$r['code'],'displayName'=>(string)$r['display_name']];return $rows; } /** @return array */ private function collector(string $value): array { $st=$this->pdo->prepare("SELECT id,code,display_name FROM board_members WHERE is_active=1 AND handles_money=1 AND (LOWER(code)=LOWER(:code) OR LOWER(display_name)=LOWER(:display)) ORDER BY id LIMIT 1");$st->execute([':code'=>trim($value),':display'=>trim($value)]);$r=$st->fetch(PDO::FETCH_ASSOC);if(!is_array($r))throw new ApiException('COLLECTOR_INVALID','Izaberite člana odbora koji je primio uplatu.',400);return ['id'=>(int)$r['id'],'code'=>(string)$r['code'],'displayName'=>(string)$r['display_name']]; } /** @return array */ private function verifyCanary(string $requestId,int $expected,array $candidate,bool $confirmBridge): array { $payment=$this->paymentByRequest($requestId); $queue=$this->queueByRequest($requestId); $year=$this->readService->getYear(2026); $readRow=null;foreach((array)($year['rows']??[]) as $r)if((int)($r['householdYearId']??0)===(int)($candidate['householdYearId']??0)){$readRow=$r;break;} $source=$this->readService->getReceiptForSource(2026,(int)($candidate['row']??0)); $readReceipt=is_array($source['receipt']??null)?$source['receipt']:[]; $bridge=$confirmBridge&&($queue['present']??false)?$this->confirmBridge($queue):[]; $checks=[ 'paymentPresent'=>($payment['present']??false)===true, 'receiptNumber105'=>(int)($payment['receiptNumber']??0)===$expected, 'paymentActive'=>($payment['paymentStatus']??'')==='ACTIVE', 'originApp'=>($payment['origin']??'')==='APP', 'receiptElectronic'=>($payment['receiptMode']??'')==='ELECTRONIC', 'receiptActive'=>($payment['receiptStatus']??'')==='ACTIVE', 'candidateExact'=>(int)($payment['householdYearId']??0)===(int)($candidate['householdYearId']??0), 'amountExact'=>abs((float)($payment['membershipTotal']??-1)-(float)($candidate['dug']??-2))<0.001, 'donationExact'=>!array_key_exists('expectedDonation',$candidate)||abs((float)($payment['donationTotal']??-1)-(float)$candidate['expectedDonation'])<0.001, 'placeExact'=>!array_key_exists('expectedPlace',$candidate)||(string)($payment['place']??'')===(string)$candidate['expectedPlace'], 'dateExact'=>!array_key_exists('expectedDate',$candidate)||(string)($payment['paymentDate']??'')===(string)$candidate['expectedDate'], 'collectorExact'=>!array_key_exists('expectedCollectorId',$candidate)||(int)($payment['collectorId']??0)===(int)$candidate['expectedCollectorId'], 'activeLinkPresent'=>(int)($payment['activeLinkCount']??0)===1, 'receiptSheetRowPresent'=>(int)($payment['receiptSheetRow']??0)>0, 'queueDone'=>($queue['status']??'')==='DONE', 'queueAttemptsPositive'=>(int)($queue['attempts']??0)>=1, 'readRowPaid'=>is_array($readRow)&&($readRow['placeno2']??false)===true, 'readRowAmountExact'=>is_array($readRow)&&abs((float)($readRow['placeno']??-1)-(float)($candidate['dug']??-2))<0.001, 'receiptVisibleFromMysql'=>(int)($readReceipt['brojNumeric']??preg_replace('/\D/','',(string)($readReceipt['broj']??'0')))===$expected, 'bridgeConfirmsExisting'=>!$confirmBridge||(($bridge['ok']??false)===true&&($bridge['alreadySynced']??false)===true&&preg_replace('/\D/','',(string)($bridge['priznanica']['broj']??''))===(string)$expected), 'sequence105'=>(int)$this->scalar("SELECT current_value FROM number_sequences WHERE sequence_name='receipt'")===105, 'maxReceipt105'=>(int)$this->scalar('SELECT COALESCE(MAX(receipt_number),0) FROM payments')===105, 'noProblemQueue'=>(int)$this->scalar("SELECT COUNT(*) FROM sync_queue WHERE queue_status IN ('PENDING','PROCESSING','FAILED','CONFLICT')")===0, 'noOpenConflict'=>(int)$this->scalar("SELECT COUNT(*) FROM sync_conflicts WHERE conflict_status='OPEN'")===0, ]; return ['valid'=>!in_array(false,$checks,true),'checks'=>$checks,'payment'=>$payment,'queue'=>$queue,'mysqlReadRow'=>$readRow,'mysqlReceipt'=>$readReceipt,'bridgeConfirmation'=>$bridge,'checkedAtUtc'=>gmdate('c')]; } /** @return array */ private function verifyVoid(string $originalRequest,array $candidate,string $voidRequest): array { $payment=$this->paymentByRequest($originalRequest);$queue=$this->queueByRequest($voidRequest);$year=$this->readService->getYear(2026);$readRow=null;foreach((array)($year['rows']??[]) as $r)if((int)($r['householdYearId']??0)===(int)($candidate['householdYearId']??0)){$readRow=$r;break;} $checks=['paymentVoid'=>($payment['paymentStatus']??'')==='VOID','receiptVoid'=>($payment['receiptStatus']??'')==='VOID','activeLinkRemoved'=>(int)($payment['activeLinkCount']??-1)===0,'voidQueueDone'=>($queue['status']??'')==='DONE','readRowUnpaid'=>is_array($readRow)&&($readRow['placeno2']??true)===false,'sequenceRemains105'=>(int)$this->scalar("SELECT current_value FROM number_sequences WHERE sequence_name='receipt'")===105,'noProblemQueue'=>(int)$this->scalar("SELECT COUNT(*) FROM sync_queue WHERE queue_status IN ('PENDING','PROCESSING','FAILED','CONFLICT')")===0]; return ['valid'=>!in_array(false,$checks,true),'checks'=>$checks,'payment'=>$payment,'queue'=>$queue,'mysqlReadRow'=>$readRow,'checkedAtUtc'=>gmdate('c')]; } /** @return array */ private function paymentByRequest(string $requestId): array { $sql="SELECT p.id,p.request_id,p.receipt_number,p.membership_total,p.donation_total,p.origin,p.payment_status,p.receipt_mode,p.payment_date,p.place,p.collector_id,r.id AS receipt_id,r.receipt_status,r.receipt_sheet_row,r.receipt_link,hy.id AS household_year_id,hy.first_name,hy.last_name,srl.sheet_row AS source_row,(SELECT COUNT(*) FROM active_payment_links x WHERE x.household_year_id=hy.id) AS active_link_count FROM payments p JOIN payment_items pi ON pi.payment_id=p.id JOIN household_years hy ON hy.id=pi.household_year_id JOIN receipts r ON r.payment_id=p.id LEFT JOIN google_sheet_documents gsd ON gsd.year=hy.year AND gsd.is_primary=1 LEFT JOIN google_sheet_tabs gst ON gst.document_id=gsd.id AND gst.tab_role='ANNUAL' LEFT JOIN sheet_row_links srl ON srl.sheet_tab_id=gst.id AND srl.entity_type='HOUSEHOLD_YEAR' AND srl.entity_id=hy.id WHERE p.request_id=:request LIMIT 1"; $st=$this->pdo->prepare($sql);$st->execute([':request'=>$requestId]);$r=$st->fetch(PDO::FETCH_ASSOC);if(!is_array($r))return ['present'=>false,'requestId'=>$requestId]; return ['present'=>true,'paymentId'=>(int)$r['id'],'requestId'=>(string)$r['request_id'],'receiptNumber'=>(int)$r['receipt_number'],'membershipTotal'=>(float)$r['membership_total'],'donationTotal'=>(float)$r['donation_total'],'origin'=>(string)$r['origin'],'paymentStatus'=>(string)$r['payment_status'],'receiptMode'=>(string)$r['receipt_mode'],'paymentDate'=>(string)$r['payment_date'],'place'=>(string)$r['place'],'collectorId'=>(int)$r['collector_id'],'receiptId'=>(int)$r['receipt_id'],'receiptStatus'=>(string)$r['receipt_status'],'receiptSheetRow'=>(int)($r['receipt_sheet_row']??0),'receiptLinkPresent'=>trim((string)($r['receipt_link']??''))!=='','householdYearId'=>(int)$r['household_year_id'],'ime'=>(string)$r['first_name'],'prezime'=>(string)$r['last_name'],'sourceRow'=>(int)($r['source_row']??0),'activeLinkCount'=>(int)$r['active_link_count']]; } /** @return array */ private function queueByRequest(string $requestId): array { $st=$this->pdo->prepare("SELECT id,queue_status,attempts,payload_json,last_error,completed_at FROM sync_queue WHERE dedupe_key=:key LIMIT 1");$st->execute([':key'=>'WEB:'.$requestId]);$r=$st->fetch(PDO::FETCH_ASSOC);if(!is_array($r))return ['present'=>false,'requestId'=>$requestId];return ['present'=>true,'id'=>(int)$r['id'],'status'=>(string)$r['queue_status'],'attempts'=>(int)$r['attempts'],'payloadJson'=>(string)($r['payload_json']??''),'lastError'=>(string)($r['last_error']??''),'completedAt'=>$r['completed_at']??null]; } /** @param array $queue @return array */ private function confirmBridge(array $queue): array { $payload=json_decode((string)($queue['payloadJson']??''),true);$job=is_array($payload)&&is_array($payload['job']??null)?$payload['job']:null;if(!is_array($job))return ['ok'=>false,'error'=>'Queue job nije pronađen.']; $configPath=$this->privateRoot.'/phase7c-write-sync.json';$config=is_file($configPath)?json_decode((string)file_get_contents($configPath),true):null;if(!is_array($config))return ['ok'=>false,'error'=>'Sync konfiguracija nije pronađena.']; $body=json_encode(['action'=>'syncJob','syncKey'=>(string)($config['bridgeKey']??''),'job'=>$job],JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR); $url=(string)($config['bridgeUrl']??''); if(function_exists('curl_init')){$ch=curl_init($url);curl_setopt_array($ch,[CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>$body,CURLOPT_HTTPHEADER=>['Content-Type: text/plain;charset=utf-8','Accept: application/json'],CURLOPT_RETURNTRANSFER=>true,CURLOPT_FOLLOWLOCATION=>true,CURLOPT_MAXREDIRS=>5,CURLOPT_CONNECTTIMEOUT=>10,CURLOPT_TIMEOUT=>60,CURLOPT_SSL_VERIFYPEER=>true,CURLOPT_SSL_VERIFYHOST=>2,CURLOPT_USERAGENT=>'SOGR-R3-Verify/'.self::VERSION]);$raw=curl_exec($ch);$status=(int)curl_getinfo($ch,CURLINFO_RESPONSE_CODE);$err=curl_error($ch);curl_close($ch);if($raw===false)return ['ok'=>false,'error'=>$err,'httpStatus'=>$status];} else{$ctx=stream_context_create(['http'=>['method'=>'POST','header'=>"Content-Type: text/plain;charset=utf-8\r\nAccept: application/json\r\n",'content'=>$body,'timeout'=>60,'ignore_errors'=>true]]);$raw=@file_get_contents($url,false,$ctx);$status=200;if($raw===false)return ['ok'=>false,'error'=>'Bridge nije dostupan.'];} try{$d=json_decode((string)$raw,true,512,JSON_THROW_ON_ERROR);return is_array($d)?$d:['ok'=>false,'error'=>'Bridge odgovor nije objekat.','httpStatus'=>$status];}catch(Throwable $e){return ['ok'=>false,'error'=>'Bridge nije vratio JSON: '.$e->getMessage(),'httpStatus'=>$status,'bodyPrefix'=>mb_substr((string)$raw,0,300)];} } /** @return array */ private function fileGate(): array { $paths=['productionIndex'=>[$this->documentRoot.'/api/v1/index.php',self::EXPECTED_INDEX_SHA256],'productionClient'=>[$this->documentRoot.'/evidencija.html',self::EXPECTED_CLIENT_SHA256],'writeService'=>[$this->privateRoot.'/src/Service/Phase7CWriteService.php',self::EXPECTED_WRITE_SHA256],'syncService'=>[$this->privateRoot.'/src/Service/Phase7CSyncService.php',self::EXPECTED_SYNC_SHA256],'readService'=>[$this->privateRoot.'/src/Service/Phase7BReadService.php',self::EXPECTED_READ_SHA256],'cutoverEndpoint'=>[$this->documentRoot.'/api/v1/phase7c-a6-r2-r1.php',self::EXPECTED_R1_ENDPOINT_SHA256],'r2Endpoint'=>[$this->documentRoot.'/api/v1/phase7c-a6-r2-r2.php',self::EXPECTED_R2_ENDPOINT_SHA256],'r2Service'=>[$this->privateRoot.'/src/Service/Phase7CA6R2R2PostCutoverSmokeService.php',self::EXPECTED_R2_SERVICE_SHA256],'r2Evidence'=>[$this->privateRoot.'/evidence/phase7c-a6-r2-r2-result.json',self::EXPECTED_R2_RESULT_SHA256]]; $checks=[];$files=[];foreach($paths as $name=>[$path,$expected]){$actual=is_file($path)?(string)hash_file('sha256',$path):'';$exact=$actual!==''&&hash_equals($expected,$actual);$checks[$name.'Exact']=$exact;$files[$name]=['path'=>$path,'expectedSha256'=>$expected,'actualSha256'=>$actual,'sizeBytes'=>is_file($path)?filesize($path):null,'exact'=>$exact];} return ['valid'=>!in_array(false,$checks,true),'checks'=>$checks,'files'=>$files,'fingerprint'=>hash('sha256',self::json(array_map(static fn($x)=>$x['actualSha256'],$files)))]; } /** @return array */ private function settingsGate(): array { $keys=['phase_7c_status','primary_data_store','phase_7c_version','financial_migration_status','financial_migration_locked','phase_7a_status','schema_version'];$ph=implode(',',array_fill(0,count($keys),'?'));$st=$this->pdo->prepare("SELECT setting_key,setting_value FROM app_settings WHERE setting_key IN ($ph)");$st->execute($keys);$v=array_fill_keys($keys,'');foreach($st->fetchAll(PDO::FETCH_ASSOC) as $r)$v[(string)$r['setting_key']]=(string)$r['setting_value'];$checks=['writeActive'=>$v['phase_7c_status']==='WRITE_ACTIVE','mysqlPrimary'=>$v['primary_data_store']==='MYSQL','phaseVersion'=>$v['phase_7c_version']==='7C-A6-R2-R1.1','migrationCompleted'=>$v['financial_migration_status']==='COMPLETED','migrationLocked'=>$v['financial_migration_locked']==='1','shadowReadReady'=>$v['phase_7a_status']==='SHADOW_READ_READY','schemaVersion'=>$v['schema_version']==='1.1.0'];return ['valid'=>!in_array(false,$checks,true),'checks'=>$checks,'values'=>$v,'fingerprint'=>hash('sha256',self::json($v))]; } /** @return array */ private function databaseState(): array { $q=['DONE'=>0,'PENDING'=>0,'PROCESSING'=>0,'FAILED'=>0,'CONFLICT'=>0];foreach($this->pdo->query('SELECT queue_status,COUNT(*) c FROM sync_queue GROUP BY queue_status')->fetchAll(PDO::FETCH_ASSOC) as $r)$q[(string)$r['queue_status']]=(int)$r['c']; $v=['sequence'=>(int)$this->scalar("SELECT current_value FROM number_sequences WHERE sequence_name='receipt'"),'maxReceipt'=>(int)$this->scalar('SELECT COALESCE(MAX(receipt_number),0) FROM payments'),'maxActive'=>(int)$this->scalar("SELECT COALESCE(MAX(receipt_number),0) FROM payments WHERE payment_status='ACTIVE'"),'payment105'=>(int)$this->scalar('SELECT COUNT(*) FROM payments WHERE receipt_number=105'),'payments'=>(int)$this->scalar('SELECT COUNT(*) FROM payments'),'activePayments'=>(int)$this->scalar("SELECT COUNT(*) FROM payments WHERE payment_status='ACTIVE'"),'paymentItems'=>(int)$this->scalar('SELECT COUNT(*) FROM payment_items'),'receipts'=>(int)$this->scalar('SELECT COUNT(*) FROM receipts'),'activeReceipts'=>(int)$this->scalar("SELECT COUNT(*) FROM receipts WHERE receipt_status='ACTIVE'"),'activeLinks'=>(int)$this->scalar('SELECT COUNT(*) FROM active_payment_links'),'membershipTotal'=>(string)$this->scalar("SELECT CAST(COALESCE(SUM(membership_total),0) AS CHAR) FROM payments WHERE payment_status='ACTIVE'"),'donationTotal'=>(string)$this->scalar("SELECT CAST(COALESCE(SUM(donation_total),0) AS CHAR) FROM payments WHERE payment_status='ACTIVE'"),'pendingOrProblemSync'=>$q['PENDING']+$q['PROCESSING']+$q['FAILED']+$q['CONFLICT'],'openConflicts'=>(int)$this->scalar("SELECT COUNT(*) FROM sync_conflicts WHERE conflict_status='OPEN'"),'stuckIdempotency'=>(int)$this->scalar("SELECT COUNT(*) FROM idempotency_requests WHERE request_status='PROCESSING' AND updated_at$v['sequence']===104,'maxReceipt104'=>$v['maxReceipt']===104,'maxActive104'=>$v['maxActive']===104,'receipt105Free'=>$v['payment105']===0,'queueClean'=>$v['pendingOrProblemSync']===0,'noConflicts'=>$v['openConflicts']===0,'noStuck'=>$v['stuckIdempotency']===0]; $business=$v;unset($business['pendingOrProblemSync'],$business['openConflicts'],$business['stuckIdempotency']);return ['validBefore'=>!in_array(false,$checks,true),'checks'=>$checks,'values'=>$v,'queue'=>$q,'businessFingerprint'=>hash('sha256',self::json($business)),'queueFingerprint'=>hash('sha256',self::json(['queue'=>$q,'open'=>$v['openConflicts'],'stuck'=>$v['stuckIdempotency']]))]; } /** @return array */ private function r2Evidence(): array { $path=$this->privateRoot.'/evidence/phase7c-a6-r2-r2-result.json';if(!is_file($path))return ['valid'=>false,'path'=>$path,'error'=>'Evidence nije instaliran.'];try{$d=json_decode((string)file_get_contents($path),true,512,JSON_THROW_ON_ERROR);}catch(Throwable $e){return ['valid'=>false,'path'=>$path,'error'=>$e->getMessage()];}$checks=['hashExact'=>hash_equals(self::EXPECTED_R2_RESULT_SHA256,(string)hash_file('sha256',$path)),'phaseExact'=>(string)($d['phase']??'')==='7C-A6-R2-R2','validTrue'=>($d['valid']??false)===true,'smokeComplete'=>($d['smokeTestCompleted']??false)===true,'writesZero'=>(int)($d['mysqlBusinessWrites']??-1)===0,'nextStep'=>(string)($d['nextStep']??'')==='READY_FOR_7C_A6_R2_R3_CONTROLLED_FIRST_TRANSACTION_CANARY'];return ['valid'=>!in_array(false,$checks,true),'checks'=>$checks,'path'=>$path,'sha256'=>hash_file('sha256',$path),'generatedAt'=>$d['generatedAt']??$d['checkedAtUtc']??null]; } /** @param array $plan @param array $session */ private function validatePlanOwner(array $plan,array $session): void { if((string)($plan['phase']??'')!==self::PHASE||(string)($plan['version']??'')!==self::VERSION)throw new ApiException('R3_PLAN_VERSION_INVALID','Plan pripada drugoj fazi.',409);if((int)($plan['expectedReceiptNumber']??0)!==105)throw new ApiException('R3_PLAN_RECEIPT_INVALID','Plan ne očekuje priznanicu 00105.',409);if((int)($plan['userId']??0)!==(int)($session['user']['id']??-1))throw new ApiException('R3_PLAN_USER_MISMATCH','Plan pripada drugom korisniku.',403);if(!preg_match('/^PHASE7C-A6-R2-R3-[a-f0-9-]{36}$/D',(string)($plan['requestId']??'')))throw new ApiException('R3_REQUEST_ID_INVALID','Canary requestId nije ispravan.',400); } private function money(mixed $v,float $min,float $max): float{$s=str_replace(['€',' '],'',trim((string)$v));if(str_contains($s,',')&&!str_contains($s,'.'))$s=str_replace(',','.',$s);if($s==='')return 0.0;if(!is_numeric($s))throw new ApiException('DONATION_INVALID','Donacija nije ispravan broj.',400);$n=round((float)$s,2);if($n<$min||$n>$max)throw new ApiException('DONATION_INVALID','Donacija je van dozvoljenog opsega.',400);return $n;} private function scalar(string $sql): mixed{return $this->pdo->query($sql)->fetchColumn();} private function uuid(): string{$d=random_bytes(16);$d[6]=chr((ord($d[6])&0x0f)|0x40);$d[8]=chr((ord($d[8])&0x3f)|0x80);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($d),4));} /** @param array $result */ private function writeEvidence(array $result,string $requestId): void{$dir=$this->privateRoot.'/backups/phase7c-a6-r2-r3/'.gmdate('Ymd-His').'-'.substr(hash('sha256',$requestId),0,12);if(!is_dir($dir)&&!mkdir($dir,0700,true)&&!is_dir($dir))return;$path=$dir.'/canary-result.json';@file_put_contents($path,json_encode($result,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT|JSON_PRESERVE_ZERO_FRACTION|JSON_THROW_ON_ERROR),LOCK_EX);@chmod($path,0600);} private static function json(mixed $v): string{return json_encode($v,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);} }