pdo=$pdo??Database::connection(); if($privateRoot!==null){$this->privateRoot=rtrim($privateRoot,'/\\');} else{$dr=isset($_SERVER['DOCUMENT_ROOT'])?rtrim((string)$_SERVER['DOCUMENT_ROOT'],'/\\'):dirname(__DIR__,3);$this->privateRoot=dirname($dr).'/sogr_private';} } /** @return array */ public function status():array { $stage='snapshot'; try{ $db=$this->snapshot(); $stage='bridge_health'; $health=$this->bridgeRequest('bridgeHealth',[]); $stage='bridge_preflight'; $preflight=$this->bridgeRequest('preflightSync',['job'=>$this->normalizedJob($db)]); $decision=$this->decision($db,$health,$preflight); return ['phase'=>self::PHASE,'version'=>self::VERSION,'mode'=>self::MODE,'valid'=>$decision['valid'],'diagnosticOnly'=>true,'writesPerformed'=>false,'mysqlWrites'=>0,'googleSheetsWrites'=>0,'database'=>$this->publicDb($db),'bridge'=>['health'=>$health,'preflight'=>$preflight],'decision'=>$decision,'confirmationRequired'=>self::CONFIRMATION,'nextStep'=>$decision['alreadyComplete']?'DOWNLOAD_ALREADY_COMPLETE_RESULT':($decision['valid']?'CONFIRM_FINAL_SYNC_FINALIZATION':'STOP_AND_SEND_R3_R8_STATUS'),'checkedAtUtc'=>gmdate('c')]; }catch(Throwable $e){return $this->errorResult('status',$stage,$e);} } /** @return array */ public function recover(string $confirmation):array { if(!hash_equals(self::CONFIRMATION,trim($confirmation))){return ['phase'=>self::PHASE,'version'=>self::VERSION,'mode'=>self::MODE,'valid'=>false,'error'=>['code'=>'CONFIRMATION_INVALID','message'=>'Potvrda nije ispravna.'],'writesPerformed'=>false,'checkedAtUtc'=>gmdate('c')];} $stage='acquire_lock'; $lock='sogr-r3-r8-queue-10'; try{ $st=$this->pdo->prepare('SELECT GET_LOCK(:lock_name,10)');$st->execute([':lock_name'=>$lock]); if((int)$st->fetchColumn()!==1)throw new ApiException('R3_R8_LOCK_BUSY','Završni recovery je već u toku.',409); $stage='snapshot_before';$before=$this->snapshot(); $stage='bridge_health_before';$healthBefore=$this->bridgeRequest('bridgeHealth',[]); $stage='bridge_preflight_before';$preBefore=$this->bridgeRequest('preflightSync',['job'=>$this->normalizedJob($before)]); $decision=$this->decision($before,$healthBefore,$preBefore); if(!$decision['valid'])throw new ApiException('R3_R8_GATE_NOT_READY','Završna kapija nije čista.',409,['decision'=>$decision]); $bridgeWrite=false;$bridgeResult=null;$receipt=null; if(($preBefore['alreadySynced']??false)===true && is_array($preBefore['existingReceipt']??null)){ $receipt=$preBefore['existingReceipt']; $bridgeResult=['ok'=>true,'alreadySynced'=>true,'businessAction'=>'pay','requestId'=>self::REQUEST_ID,'priznanica'=>$receipt]; $stage='metadata_only_existing_sheets_receipt'; }elseif($decision['alreadyComplete']){ $receipt=$this->receiptFromDb($before); $bridgeResult=['ok'=>true,'alreadySynced'=>true,'priznanica'=>$receipt]; $stage='already_complete'; }else{ $stage='bridge_sync_job'; $bridgeResult=$this->bridgeRequest('syncJob',['job'=>$this->normalizedJob($before)]); $bridgeWrite=true; $receipt=is_array($bridgeResult['priznanica']??null)?$bridgeResult['priznanica']:null; } if(!is_array($receipt))throw new ApiException('R3_R8_RECEIPT_RESULT_MISSING','Bridge nije vratio podatke priznanice 00105.',502,['bridgeResult'=>$bridgeResult]); $stage='validate_bridge_receipt';$receipt=$this->validateReceipt($receipt); $stage='finalize_mysql_metadata'; $metadata=$this->finalizeMetadata($receipt,$bridgeResult); $stage='snapshot_after';$after=$this->snapshot(); $stage='bridge_health_after';$healthAfter=$this->bridgeRequest('bridgeHealth',[]); $stage='bridge_preflight_after';$preAfter=$this->bridgeRequest('preflightSync',['job'=>$this->normalizedJob($after)]); $checks=$this->successChecks($after,$healthAfter,$preAfter); $valid=!in_array(false,$checks,true); $result=['phase'=>self::PHASE,'version'=>self::VERSION,'mode'=>self::MODE,'valid'=>$valid,'recoveryCompleted'=>$valid,'paymentCommitRepeated'=>false,'voidCalled'=>false,'bridgeWritePerformed'=>$bridgeWrite,'metadataFinalized'=>true,'mysqlFinancialValuesChanged'=>false,'googleSheetsWrites'=>$bridgeWrite?1:0,'before'=>['database'=>$this->publicDb($before),'bridgeHealth'=>$healthBefore,'preflight'=>$preBefore],'bridgeResult'=>$bridgeResult,'metadata'=>$metadata,'after'=>['database'=>$this->publicDb($after),'bridgeHealth'=>$healthAfter,'preflight'=>$preAfter],'checks'=>$checks,'message'=>$valid?'USPEŠNO: priznanica 00105 je potvrđena u MySQL-u i Google Sheets-u; queue 10 je DONE.':'Finalizacija je izvršena, ali završna provera nije potpuno zelena.','readyForNextPhase'=>$valid,'nextStep'=>$valid?'READY_FOR_7C_A6_R2_R4_RECEIPT_PDF_PRINT_AND_NORMAL_APP_VALIDATION':'STOP_AND_SEND_R3_R8_RESULT','checkedAtUtc'=>gmdate('c')]; $this->writeEvidence($result);return $result; }catch(Throwable $e){ $result=$this->errorResult('recover',$stage,$e); try{$result['automaticVerification']=$this->safeVerification();}catch(Throwable $ignored){} $this->writeEvidence($result);return $result; }finally{ try{$r=$this->pdo->prepare('SELECT RELEASE_LOCK(:lock_name)');$r->execute([':lock_name'=>$lock]);}catch(Throwable $ignored){} } } /** @return array */ private function snapshot():array { $p=$this->pdo->prepare("SELECT p.id,p.request_id,p.receipt_number,p.payment_date,p.membership_total,p.donation_total,p.payment_status,p.row_version,r.id receipt_id,r.receipt_sheet_year,r.receipt_sheet_row,r.receipt_link,r.receipt_status,r.row_version receipt_row_version FROM payments p JOIN receipts r ON r.payment_id=p.id WHERE p.id=:payment_id AND p.receipt_number=:receipt_number LIMIT 1"); $p->execute([':payment_id'=>self::PAYMENT_ID,':receipt_number'=>self::RECEIPT_NUMBER]);$payment=$p->fetch(PDO::FETCH_ASSOC)?:null; $q=$this->pdo->prepare("SELECT id,direction,entity_type,entity_id,operation_name,dedupe_key,payload_json,queue_status,attempts,next_attempt_at,locked_at,locked_by,last_error,completed_at,created_at,updated_at FROM sync_queue WHERE id=:queue_id LIMIT 1"); $q->execute([':queue_id'=>self::QUEUE_ID]);$queue=$q->fetch(PDO::FETCH_ASSOC)?:null; if(is_array($queue)){$queue['payload']=$this->decode((string)($queue['payload_json']??''));unset($queue['payload_json']);} $link=$this->pdo->prepare("SELECT srl.id,srl.sheet_tab_id,srl.entity_type,srl.entity_id,srl.sheet_row,srl.db_version,srl.sheet_version,srl.last_source,srl.last_synced_at FROM sheet_row_links srl WHERE srl.entity_type='RECEIPT' AND srl.entity_id=:receipt_id ORDER BY srl.id"); $link->execute([':receipt_id'=>self::RECEIPT_ID]);$receiptLinks=$link->fetchAll(PDO::FETCH_ASSOC); $seq=(int)$this->pdo->query("SELECT current_value FROM number_sequences WHERE sequence_name='receipt'")->fetchColumn(); $problem=(int)$this->pdo->query("SELECT COUNT(*) FROM sync_queue WHERE direction='DB_TO_SHEETS' AND queue_status IN ('PENDING','PROCESSING','FAILED','CONFLICT')")->fetchColumn(); $conf=(int)$this->pdo->query("SELECT COUNT(*) FROM sync_conflicts WHERE conflict_status='OPEN'")->fetchColumn(); $idem=$this->pdo->prepare('SELECT request_status,response_body FROM idempotency_requests WHERE request_id=:request_id LIMIT 1'); $idem->execute([':request_id'=>self::REQUEST_ID]);$idempotency=$idem->fetch(PDO::FETCH_ASSOC)?:null; if(is_array($idempotency)){$idempotency['response']=$this->decode((string)($idempotency['response_body']??''));unset($idempotency['response_body']);} return ['payment'=>$payment,'queue'=>$queue,'receiptLinks'=>$receiptLinks,'sequence'=>$seq,'problemQueueCount'=>$problem,'openConflicts'=>$conf,'idempotency'=>$idempotency,'fingerprint'=>hash('sha256',self::json([$payment,$queue,$receiptLinks,$seq,$problem,$conf,$idempotency]))]; } /** @return array */ private function publicDb(array $s):array { $p=$s['payment']??[];$q=$s['queue']??[]; return ['sequence'=>$s['sequence']??null,'paymentId'=>$p['id']??null,'paymentStatus'=>$p['payment_status']??null,'receiptNumber'=>isset($p['receipt_number'])?str_pad((string)$p['receipt_number'],5,'0',STR_PAD_LEFT):null,'receiptStatus'=>$p['receipt_status']??null,'receiptSheetRow'=>$p['receipt_sheet_row']??null,'queueId'=>$q['id']??null,'queueStatus'=>$q['queue_status']??null,'queueAttempts'=>$q['attempts']??null,'queueLastError'=>$q['last_error']??null,'problemQueueCount'=>$s['problemQueueCount']??null,'openConflicts'=>$s['openConflicts']??null,'receiptLinkCount'=>count($s['receiptLinks']??[]),'idempotencyStatus'=>$s['idempotency']['request_status']??null,'fingerprint'=>$s['fingerprint']??null]; } /** @return array */ private function normalizedJob(array $s):array { $job=$s['queue']['payload']['job']??null;if(!is_array($job))throw new ApiException('R3_R8_JOB_MISSING','Queue 10 nema job payload.',500); $action=(string)(($job['businessAction']??'')?:($job['action']??''));if($action!=='pay')throw new ApiException('R3_R8_ACTION_INVALID','Queue 10 nije pay posao.',409); $payload=is_array($job['payload']??null)?$job['payload']:[];$date=trim((string)($payload['datum']??'')); if(preg_match('/^(\d{4})-(\d{2})-(\d{2})$/',$date,$m)===1){$y=(int)$m[1];$mo=(int)$m[2];$d=(int)$m[3];if(!checkdate($mo,$d,$y))throw new ApiException('R3_R8_DATE_INVALID','Datum nije ispravan.',422);$payload['datum']=sprintf('%02d.%02d.%04d.',$d,$mo,$y);} $payload['expectedReceiptNumber']=self::RECEIPT_NUMBER;$job['expectedReceiptNumber']=self::RECEIPT_NUMBER;$job['payload']=$payload;return $job; } /** @return array */ private function decision(array $db,array $health,array $pre):array { $p=$db['payment']??[];$q=$db['queue']??[];$existing=$pre['existingReceipt']??null; $sheetsPresent=($pre['alreadySynced']??false)===true&&is_array($existing)&&$this->receiptNumber($existing)===self::RECEIPT_NUMBER; $metadataComplete=(string)($q['queue_status']??'')==='DONE'&&(int)($p['receipt_sheet_row']??0)>0&&count($db['receiptLinks']??[])===1&&(int)($db['problemQueueCount']??-1)===0; $checks=['paymentExact'=>(int)($p['id']??0)===self::PAYMENT_ID&&(string)($p['request_id']??'')===self::REQUEST_ID&&(int)($p['receipt_number']??0)===105&&(string)($p['payment_status']??'')==='ACTIVE'&&(string)($p['receipt_status']??'')==='ACTIVE','amountExact'=>(float)($p['membership_total']??-1)===25.0&&(float)($p['donation_total']??-1)===0.0,'sequence105'=>(int)($db['sequence']??0)===105,'queueExact'=>(int)($q['id']??0)===10&&(string)($q['entity_type']??'')==='PAYMENT'&&(int)($q['entity_id']??0)===1790,'queueRecoverable'=>in_array((string)($q['queue_status']??''),['FAILED','PENDING','PROCESSING','DONE'],true),'noOpenConflicts'=>(int)($db['openConflicts']??-1)===0,'bridgeHealthy'=>($health['ok']??false)===true&&($health['ready']??false)===true,'preflightReady'=>($pre['ok']??false)===true&&(($pre['ready']??false)===true||$sheetsPresent),'receiptOrderSafe'=>$sheetsPresent||((int)($pre['maxReceiptNumber']??-1)===104&&(int)($pre['expectedReceiptNumber']??0)===105)]; return ['valid'=>!in_array(false,$checks,true),'checks'=>$checks,'sheetsReceipt105Present'=>$sheetsPresent,'metadataComplete'=>$metadataComplete,'alreadyComplete'=>$sheetsPresent&&$metadataComplete,'recoveryPath'=>$sheetsPresent?'METADATA_ONLY':'ONE_IDEMPOTENT_BRIDGE_WRITE_THEN_METADATA']; } /** @return array */ private function validateReceipt(array $r):array { $n=$this->receiptNumber($r);$row=(int)($r['receiptRow']??0);$source=(int)($r['sourceRow']??0);$year=(int)(($r['receiptYear']??0)?:($r['year']??0)?:($r['godina']??0)); if($n!==105||$row<2||($source!==0&&$source!==147)||($year!==0&&$year!==2026))throw new ApiException('R3_R8_BRIDGE_RECEIPT_MISMATCH','Bridge podatak ne odgovara priznanici 00105.',502,['receipt'=>$r]); $r['receiptRow']=$row;return $r; } /** @return array */ private function finalizeMetadata(array $receipt,array $bridgeResult):array { $row=(int)$receipt['receiptRow'];$link=trim((string)($receipt['link']??''));$hash=hash('sha256',self::json($receipt)); $this->pdo->beginTransaction(); try{ $q=$this->pdo->prepare('SELECT id,queue_status,payload_json FROM sync_queue WHERE id=:queue_id FOR UPDATE');$q->execute([':queue_id'=>10]);$queue=$q->fetch(PDO::FETCH_ASSOC);if(!is_array($queue))throw new ApiException('R3_R8_QUEUE_MISSING','Queue 10 nije pronađen.',404); $p=$this->pdo->prepare('SELECT p.id,p.payment_status,r.id receipt_id,r.receipt_status,r.row_version FROM payments p JOIN receipts r ON r.payment_id=p.id WHERE p.id=:payment_id FOR UPDATE');$p->execute([':payment_id'=>1790]);$pay=$p->fetch(PDO::FETCH_ASSOC);if(!is_array($pay)||(string)$pay['payment_status']!=='ACTIVE'||(string)$pay['receipt_status']!=='ACTIVE')throw new ApiException('R3_R8_PAYMENT_NOT_ACTIVE','Uplata 00105 više nije aktivna.',409); $newVersion=(int)$pay['row_version']+1; $ur=$this->pdo->prepare("UPDATE receipts SET receipt_sheet_row=:sheet_row,receipt_link=COALESCE(NULLIF(:receipt_link,''),receipt_link),row_version=row_version+1 WHERE id=:receipt_id");$ur->execute([':sheet_row'=>$row,':receipt_link'=>$link,':receipt_id'=>97]); $tab=$this->pdo->prepare("SELECT gst.id FROM google_sheet_tabs gst JOIN google_sheet_documents gsd ON gsd.id=gst.document_id WHERE gsd.year=:doc_year AND gsd.is_primary=1 AND gsd.is_active=1 AND gst.tab_role='RECEIPTS' AND gst.is_active=1 LIMIT 1");$tab->execute([':doc_year'=>2026]);$tabId=(int)$tab->fetchColumn();if($tabId<=0)throw new ApiException('R3_R8_RECEIPT_TAB_MISSING','2026 PRIZNANICE tab nije pronađen.',500); $byRow=$this->pdo->prepare('SELECT id,entity_type,entity_id FROM sheet_row_links WHERE sheet_tab_id=:tab_id AND sheet_row=:sheet_row FOR UPDATE');$byRow->execute([':tab_id'=>$tabId,':sheet_row'=>$row]);$rowLink=$byRow->fetch(PDO::FETCH_ASSOC); if(is_array($rowLink)&&((string)$rowLink['entity_type']!=='RECEIPT'||(int)$rowLink['entity_id']!==97))throw new ApiException('R3_R8_SHEET_ROW_CONFLICT','Sheets red je vezan za drugi entitet.',409,['rowLink'=>$rowLink]); $byEntity=$this->pdo->prepare("SELECT id FROM sheet_row_links WHERE sheet_tab_id=:tab_id AND entity_type='RECEIPT' AND entity_id=:entity_id FOR UPDATE");$byEntity->execute([':tab_id'=>$tabId,':entity_id'=>97]);$entityLink=(int)$byEntity->fetchColumn(); if($entityLink>0){$ul=$this->pdo->prepare("UPDATE sheet_row_links SET sheet_row=:sheet_row,db_version=:db_version,sheet_version=sheet_version+1,last_synced_hash=:sync_hash,last_source='DB',last_synced_at=UTC_TIMESTAMP() WHERE id=:link_id");$ul->execute([':sheet_row'=>$row,':db_version'=>$newVersion,':sync_hash'=>$hash,':link_id'=>$entityLink]);} else{$il=$this->pdo->prepare("INSERT INTO sheet_row_links(sheet_tab_id,entity_type,entity_id,entity_uuid,sheet_row,db_version,sheet_version,last_synced_hash,last_source,last_synced_at) VALUES(:tab_id,'RECEIPT',:entity_id,NULL,:sheet_row,:db_version,1,:sync_hash,'DB',UTC_TIMESTAMP())");$il->execute([':tab_id'=>$tabId,':entity_id'=>97,':sheet_row'=>$row,':db_version'=>$newVersion,':sync_hash'=>$hash]);} $payload=$this->decode((string)($queue['payload_json']??''));$payload['bridgeResult']=$bridgeResult;$payload['syncedAtUtc']=gmdate('c');$payload['r3R8Finalization']=['version'=>self::VERSION,'receiptRow'=>$row,'finalizedAtUtc'=>gmdate('c')]; $uq=$this->pdo->prepare("UPDATE sync_queue SET payload_json=:payload_json,queue_status='DONE',locked_at=NULL,locked_by=NULL,last_error=NULL,completed_at=COALESCE(completed_at,UTC_TIMESTAMP()),next_attempt_at=NULL WHERE id=:queue_id");$uq->execute([':payload_json'=>self::json($payload),':queue_id'=>10]); $ir=$this->pdo->prepare('SELECT response_body FROM idempotency_requests WHERE request_id=:request_id FOR UPDATE');$ir->execute([':request_id'=>self::REQUEST_ID]);$resp=$this->decode((string)$ir->fetchColumn());$resp['ok']=true;$resp['mysqlCommitted']=true;$resp['receiptNumber']='00105';$resp['sync']=['queueId'=>10,'status'=>'DONE','sheetsSynced'=>true,'retryScheduled'=>false,'error'=>null];$resp['syncPending']=false; $ui=$this->pdo->prepare("UPDATE idempotency_requests SET request_status='DONE',response_body=:response_body WHERE request_id=:request_id");$ui->execute([':response_body'=>self::json($resp),':request_id'=>self::REQUEST_ID]); $this->pdo->commit();return ['queueId'=>10,'queueStatus'=>'DONE','receiptSheetRow'=>$row,'receiptLinkStored'=>$link!=='','sheetTabId'=>$tabId,'receiptLinkId'=>$entityLink?:null,'idempotencyUpdated'=>true]; }catch(Throwable $e){if($this->pdo->inTransaction())$this->pdo->rollBack();throw $e;} } /** @return array */ private function successChecks(array $db,array $health,array $pre):array { $p=$db['payment']??[];$q=$db['queue']??[];$existing=$pre['existingReceipt']??null; return ['paymentActive'=>(string)($p['payment_status']??'')==='ACTIVE','receiptActive'=>(string)($p['receipt_status']??'')==='ACTIVE','sequence105'=>(int)($db['sequence']??0)===105,'queueDone'=>(string)($q['queue_status']??'')==='DONE','receiptSheetRowStored'=>(int)($p['receipt_sheet_row']??0)>0,'oneReceiptLink'=>count($db['receiptLinks']??[])===1,'problemQueuesZero'=>(int)($db['problemQueueCount']??-1)===0,'conflictsZero'=>(int)($db['openConflicts']??-1)===0,'bridgeMaxAtLeast105'=>(int)($health['maxReceiptNumber']??0)>=105,'bridgeExisting105'=>($pre['alreadySynced']??false)===true&&is_array($existing)&&$this->receiptNumber($existing)===105,'idempotencyDone'=>(string)($db['idempotency']['request_status']??'')==='DONE']; } /** @return array */ private function safeVerification():array { $db=$this->snapshot();$pre=null;$health=null;$err='';try{$health=$this->bridgeRequest('bridgeHealth',[]);$pre=$this->bridgeRequest('preflightSync',['job'=>$this->normalizedJob($db)]);}catch(Throwable $e){$err=$e->getMessage();} return ['database'=>$this->publicDb($db),'bridgeHealth'=>$health,'preflight'=>$pre,'bridgeError'=>$err,'safeToRepeatPaymentCommit'=>false,'voidAllowed'=>false]; } /** @return array */ private function receiptFromDb(array $db):array{$p=$db['payment']??[];return ['broj'=>105,'receiptRow'=>(int)($p['receipt_sheet_row']??0),'link'=>(string)($p['receipt_link']??''),'year'=>2026,'sourceRow'=>147];} private function receiptNumber(array $r):int{return (int)preg_replace('/\D/','',(string)($r['broj']??($r['receiptNumber']??'')));} /** @return array */ private function bridgeRequest(string $action,array $extra):array { $config=$this->config();$body=array_merge(['action'=>$action,'syncKey'=>$config['bridgeKey']],$extra);$json=self::json($body);$response=$this->httpPost($config['bridgeUrl'],$json); try{$decoded=json_decode($response['body'],true,512,JSON_THROW_ON_ERROR);}catch(Throwable $e){throw new ApiException('R3_R8_BRIDGE_INVALID_JSON','Bridge nije vratio ispravan JSON.',502,['httpStatus'=>$response['status'],'bodyPrefix'=>substr($response['body'],0,500)],$e);} if(!is_array($decoded)||$response['status']<200||$response['status']>=300||($decoded['ok']??false)!==true)throw new ApiException('R3_R8_BRIDGE_ERROR',(string)($decoded['error']??('Bridge HTTP '.$response['status'])),502,['httpStatus'=>$response['status'],'bridgeResponse'=>$decoded]); return $decoded; } /** @return array{bridgeUrl:string,bridgeKey:string} */ private function config():array{$path=$this->privateRoot.'/phase7c-write-sync.json';if(!is_file($path))throw new ApiException('R3_R8_CONFIG_MISSING','Sync konfiguracija nije pronađena.',503);$c=json_decode((string)file_get_contents($path),true);if(!is_array($c))throw new ApiException('R3_R8_CONFIG_INVALID','Sync konfiguracija nije ispravna.',503);$url=trim((string)($c['bridgeUrl']??''));$key=trim((string)($c['bridgeKey']??''));if(!str_starts_with($url,'https://script.google.com/')||strlen($key)<48)throw new ApiException('R3_R8_CONFIG_INVALID','Bridge URL ili ključ nisu ispravni.',503);return ['bridgeUrl'=>$url,'bridgeKey'=>$key];} /** @return array{status:int,body:string} */ private function httpPost(string $url,string $body):array{if(function_exists('curl_init')){$ch=curl_init($url);if($ch===false)throw new ApiException('R3_R8_CURL_INIT','cURL nije pokrenut.',500);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=>90,CURLOPT_SSL_VERIFYPEER=>true,CURLOPT_SSL_VERIFYHOST=>2,CURLOPT_USERAGENT=>'SOGR-R3-R8/1']);$raw=curl_exec($ch);$status=(int)curl_getinfo($ch,CURLINFO_RESPONSE_CODE);$error=curl_error($ch);curl_close($ch);if($raw===false)throw new ApiException('R3_R8_BRIDGE_UNREACHABLE','Bridge nije dostupan: '.$error,502);return ['status'=>$status,'body'=>(string)$raw];}$ctx=stream_context_create(['http'=>['method'=>'POST','header'=>"Content-Type: text/plain;charset=utf-8\r\nAccept: application/json\r\n",'content'=>$body,'timeout'=>90,'ignore_errors'=>true]]);$raw=@file_get_contents($url,false,$ctx);if($raw===false)throw new ApiException('R3_R8_BRIDGE_UNREACHABLE','Bridge nije dostupan.',502);$status=200;foreach(($http_response_header??[]) as $h)if(preg_match('/^HTTP\/\S+\s+(\d{3})/',$h,$m)===1)$status=(int)$m[1];return ['status'=>$status,'body'=>(string)$raw];} /** @return array */ private function errorResult(string $operation,string $stage,Throwable $e):array{return ['phase'=>self::PHASE,'version'=>self::VERSION,'mode'=>self::MODE,'valid'=>false,'operation'=>$operation,'failedStage'=>$stage,'error'=>['code'=>'R3_R8_RUNTIME_ERROR','message'=>$e->getMessage(),'exceptionClass'=>get_class($e),'exceptionFile'=>basename($e->getFile()),'exceptionLine'=>$e->getLine()],'paymentCommitRepeated'=>false,'voidCalled'=>false,'safeToRepeatPaymentCommit'=>false,'nextStep'=>'STOP_AND_SEND_R3_R8_RESULT','checkedAtUtc'=>gmdate('c')];} private function decode(string $v):array{if(trim($v)==='')return [];try{$d=json_decode($v,true,512,JSON_THROW_ON_ERROR);return is_array($d)?$d:[];}catch(Throwable $e){return [];}} private function writeEvidence(array $r):void{$dir=$this->privateRoot.'/backups/phase7c-a6-r2-r3-r8';if(!is_dir($dir)&&!@mkdir($dir,0700,true)&&!is_dir($dir))return;@chmod($dir,0700);$p=$dir.'/result-'.gmdate('Ymd-His').'-'.substr(hash('sha256',self::json($r)),0,12).'.json';@file_put_contents($p,self::json($r),LOCK_EX);@chmod($p,0600);} private static function json(mixed $v):string{return json_encode($v,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);} }