commit melhorias1
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-10 12:58:19 +02:00
parent 63d20b10fc
commit 976271f948
17 changed files with 640 additions and 0 deletions
@@ -34,6 +34,7 @@ class InvoiceController extends Controller
'invoices' => Invoice::query()->whereIn('purchase_order_id', PurchaseOrder::query()->visibleTo($actor)->select('id'))->when($focus !== null, fn ($q) => $q->whereKey($focus))->when($focusOrder !== null, fn ($q) => $q->where('purchase_order_id', $focusOrder))->with('items')->latest('id')->paginate(15)->withQueryString()
->through(fn (Invoice $invoice) => [...$invoice->toArray(), 'canValidate' => app(InvoiceWorkflow::class)->reviewerEligible($actor, $invoice)]),
'orders' => PurchaseOrder::query()->visibleTo($actor)->operational()->with('items')->latest('id')->get()->map(fn ($order) => ['id' => $order->id, 'number' => $order->number, 'supplier' => $order->snapshot['supplier']['name'], 'items' => $order->items]),
'sgfDispatchAvailable' => config('sgf_invoice.workspace_enabled')===true && $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor),
'payablesAvailable' => SupplierPayables::enabled() && app(SupplierPayables::class)->can($actor, 'view'), 'canManage' => $actor->hasFunctions([PurchasingRole::Finance]) && ! app(OrganizationAccess::class)->maintenanceOnly($actor)])->toResponse($request);
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\SgfInvoiceDispatches;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class SgfInvoiceDispatchController extends Controller
{
private function actor(Request $r,SgfInvoiceDispatches $service):User
{ $user=$r->user('web');abort_unless($user instanceof User,403);return $service->actor($user); }
public function index(Request $r,SgfInvoiceDispatches $service)
{
$user=$this->actor($r,$service);$d=$r->validate(['page'=>'sometimes|integer|min:1|max:100000','phase'=>'nullable|in:draft,previewing,previewed,sending,uncertain,received_pending,blocked,review_required,not_sent','connection'=>'nullable|string|max:64','mapping'=>'nullable|string|max:64']);
abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
$rows=DB::table('sgf_invoice_dispatches')->where('tenant_id',$user->tenant_id)->when($d['phase']??null,fn($q,$v)=>$q->where('phase',$v))
->orderByDesc('id')->paginate(25,['id','reference','number','phase','version','updated_at'])->withQueryString();
$mapping=null;foreach($service->mappings($user)as$m)if($m['connection']===($d['connection']??null)&&$m['id']===($d['mapping']??null))$mapping=$m;
return response()->view('sgf-invoices.index',['rows'=>$rows,'options'=>$service->mappings($user),'selection'=>$mapping])->header('Cache-Control','private, no-store');
}
public function show(int $id,Request $r,SgfInvoiceDispatches $service)
{
$user=$this->actor($r,$service);$d=$r->validate(['events_page'=>'sometimes|integer|min:1|max:100000']);abort_if(array_diff(array_keys($r->query()),array_keys($d)),422);
$row=$service->row($user,$id);$events=DB::table('sgf_invoice_dispatch_events')->where('tenant_id',$user->tenant_id)->where('dispatch_id',$id)->orderByDesc('id')
->paginate(25,['actor_id','action','reason','before_state','after_state','created_at'],'events_page')->withQueryString();
return response()->view('sgf-invoices.show',['row'=>$service->presentation($user,$row),'events'=>$events,'options'=>$service->mappings($user),'transportEnabled'=>config('sgf_invoice.enabled')===true])->header('Cache-Control','private, no-store');
}
public function create(Request $r,SgfInvoiceDispatches $service)
{
$user=$this->actor($r,$service);$input=$r->except('_token');$id=$service->create($user,$input);
return redirect('/facturas/envio-sgf/'.$id);
}
public function revise(int $id,Request $r,SgfInvoiceDispatches $service)
{
$user=$this->actor($r,$service);$d=$r->validate(['version'=>'required|integer|min:1']);
$service->revise($user,$id,(int)$d['version'],$r->except('_token','version'));
return redirect('/facturas/envio-sgf/'.$id);
}
public function dispatch(int $id,Request $r,SgfInvoiceDispatches $service)
{
$user=$this->actor($r,$service);$d=$r->validate(['version'=>'required|integer|min:1','action'=>'required|in:preview,confirm,recover','reason'=>'required|string|min:10|max:2000','approval_hash'=>'nullable|string|size:64']);
abort_if(array_diff(array_keys($r->except('_token')),array_keys($d)),422);
$service->dispatch($user,$id,(int)$d['version'],$d['action'],$d['reason'],$d['approval_hash']??null);
return redirect('/facturas/envio-sgf/'.$id);
}
}
+1
View File
@@ -105,6 +105,7 @@ class InvoiceWorkflow
$request = Requisition::query()->findOrFail($order->requisition_id);
app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user);
$number = Str::upper(Str::squish($data['number']));
SgfInvoiceDispatches::assertLocalRegistration($user->tenant_id,$order->supplier_id,$number);
if ($number === '' || Invoice::query()->where('supplier_id', $order->supplier_id)->where('number', $number)->exists()) {
$this->invalid('Número de factura vazio ou já registado para este fornecedor.');
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace App\Services;
use App\Models\{User,Supplier,Invoice};
use Illuminate\Support\Facades\{Crypt,DB,Schema,Validator};
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/** Durable source intent and audit. No native invoice, payment, budget or stock writer. */
final class SgfInvoiceDispatches
{
public function actor(User $actor,bool $locked=false):User
{
abort_unless(config('sgf_invoice.workspace_enabled')===true,404);
abort_unless(auth()->id()===$actor->id&&$actor->tenant_id!==null,403);
if($locked)app(ConflictGuard::class)->lockCompany($actor);
$q=User::where('tenant_id',$actor->tenant_id)->whereKey($actor->id);if($locked)$q->lockForUpdate();$user=$q->firstOrFail();
abort_unless($user->hasFunctions(['finance'])&&!app(OrganizationAccess::class)->maintenanceOnly($user),403);
abort_unless(Schema::hasTable('sgf_invoice_dispatches')&&Schema::hasTable('sgf_invoice_dispatch_events'),503,'Estrutura de envio ainda não instalada.');
return $user;
}
public static function assertLocalRegistration(int $tenant,int $supplier,string $number):void
{
// Keep the claim even if the workspace/transport is later disabled.
if(Schema::hasTable('sgf_invoice_dispatches')&&DB::table('sgf_invoice_dispatches')->where(['tenant_id'=>$tenant,'supplier_id'=>$supplier,'number'=>$number])->exists())
throw ValidationException::withMessages(['invoice'=>'Documento reservado ao envio SGF; consultar o recibo, sem segundo registo local.']);
}
public function mappings(User $actor):array
{
$this->actor($actor);$out=[];
foreach(config('sgf_invoice.connections',[])as$c=>$connection)if(($connection['source_scope']['tenant_id']??null)===$actor->tenant_id)
foreach($connection['mappings']??[]as$m=>$entry)if(($entry['approved']??false)===true)$out[]=['connection'=>$c,'id'=>$m,'label'=>$entry['label']??$m,'mapping'=>$entry['mapping']];
return $out;
}
private function mapping(User $actor,string $connection,string $id):array
{
$all=config('sgf_invoice.connections',[]);$c=$all[$connection]??null;$m=$c['mappings'][$id]??null;
abort_unless(is_array($c)&&($c['source_scope']['tenant_id']??null)===$actor->tenant_id&&is_array($m)&&($m['approved']??false)===true,403);
abort_if(array_diff(array_keys($m),['approved','label','source_supplier_id','mapping','approved_by','approved_at']),503,'Configuração de mapeamento inválida.');
abort_unless(($m['mapping']['source_scope']['tenant_id']??null)===$actor->tenant_id&&($m['mapping']['authority']??null)==='sgf_only',403);
abort_unless(is_int($m['source_supplier_id']??null)&&Supplier::where('tenant_id',$actor->tenant_id)->whereKey($m['source_supplier_id'])->exists(),403);
return $m;
}
private function checkSource(object $row,User $actor):array
{
$m=$this->mapping($actor,$row->connection_id,$row->mapping_id);
abort_unless(hash_equals($row->mapping_hash,self::hash($m)),409,'O mapeamento mudou; exige nova conferência.');
abort_if(Invoice::where('tenant_id',$actor->tenant_id)->where('supplier_id',$row->supplier_id)->where('number',$row->number)->exists(),409,'Documento já registado no SGC. Não reenviar.');
return $m;
}
private function validated(array $input):array
{
$d=Validator::make($input,['reference'=>'required|string|min:2|max:120','connection_id'=>'required|string|max:64','mapping_id'=>'required|string|max:64','reason'=>'required|string|min:10|max:2000',
'document'=>'required|array:number,issued_on,subtotal,tax,total,items','document.number'=>'required|string|max:100','document.issued_on'=>'required|date_format:Y-m-d',
'document.subtotal'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D','document.tax'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D','document.total'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D',
'document.items'=>'required|array|min:1|max:50','document.items.*'=>'array:source_line_ref,quantity,unit,unit_price,tax_rate',
'document.items.*.source_line_ref'=>'required|string|max:120|distinct','document.items.*.quantity'=>'required|string|regex:/^\d{1,9}(\.\d{1,3})?$/D',
'document.items.*.unit'=>'required|string|max:20','document.items.*.unit_price'=>'required|string|regex:/^\d{1,12}\.\d{2}$/D','document.items.*.tax_rate'=>'required|string|regex:/^\d{1,3}(\.\d{1,2})?$/D'])->validate();
abort_if(array_diff(array_keys($input),array_keys($d)),422);
return $d;
}
public function create(User $actor,array $input):int
{
$d=$this->validated($input);
return DB::transaction(function()use($actor,$d){
$user=$this->actor($actor,true);$m=$this->mapping($user,$d['connection_id'],$d['mapping_id']);$doc=$d['document'];$doc['number']=Str::upper(Str::squish($doc['number']));
$ids=[];foreach($doc['items']as$item){$id=$m['mapping']['lines'][$item['source_line_ref']]??null;abort_unless(is_int($id)&&$id>0&&!in_array($id,$ids,true),422,'Linha sem mapeamento explícito.');$ids[]=$id;}
abort_if(Invoice::where('tenant_id',$user->tenant_id)->where('supplier_id',$m['source_supplier_id'])->where('number',$doc['number'])->exists(),409,'Documento já registado no SGC.');
abort_if(DB::table('sgf_invoice_dispatches')->where('tenant_id',$user->tenant_id)->where(fn($q)=>$q->where('reference',$d['reference'])->orWhere(fn($q)=>$q->where('supplier_id',$m['source_supplier_id'])->where('number',$doc['number'])))->exists(),409,'Já existe uma intenção para esta fonte.');
$id=DB::table('sgf_invoice_dispatches')->insertGetId(['tenant_id'=>$user->tenant_id,'supplier_id'=>$m['source_supplier_id'],'reference'=>$d['reference'],'number'=>$doc['number'],
'connection_id'=>$d['connection_id'],'mapping_id'=>$d['mapping_id'],'mapping_snapshot'=>json_encode($m,JSON_THROW_ON_ERROR),'mapping_hash'=>self::hash($m),
'document'=>json_encode($doc,JSON_THROW_ON_ERROR),'reason'=>$d['reason'],'created_by'=>$user->id,'created_at'=>now('UTC'),'updated_at'=>now('UTC')]);
$row=$this->row($user,$id);$this->event($row,$user,'draft.created',$d['reason'],null);return $id;
});
}
public function revise(User $actor,int $id,int $version,array $input):object
{
$d=$this->validated($input);
return DB::transaction(function()use($actor,$id,$version,$d){
$user=$this->actor($actor,true);$row=$this->row($user,$id,true);
abort_unless((int)$row->version===$version&&!$row->ever_sending&&$row->phase!=='received_pending'
&&(!$row->lease_until||now('UTC')->gte($row->lease_until)),409,'Fonte já enviada/em curso ou versão alterada.');
abort_unless($d['reference']===$row->reference,422,'Referência de origem imutável.');
$m=$this->mapping($user,$d['connection_id'],$d['mapping_id']);$doc=$d['document'];$doc['number']=Str::upper(Str::squish($doc['number']));
$used=[];foreach($doc['items']as$item){$line=$m['mapping']['lines'][$item['source_line_ref']]??null;abort_unless(is_int($line)&&!in_array($line,$used,true),422);$used[]=$line;}
abort_if(Invoice::where('tenant_id',$user->tenant_id)->where('supplier_id',$m['source_supplier_id'])->where('number',$doc['number'])->exists()
||DB::table('sgf_invoice_dispatches')->where('tenant_id',$user->tenant_id)->where('supplier_id',$m['source_supplier_id'])->where('number',$doc['number'])->where('id','<>',$id)->exists(),409,'Documento já reservado/registado.');
return $this->update($row,$user,['supplier_id'=>$m['source_supplier_id'],'number'=>$doc['number'],'connection_id'=>$d['connection_id'],'mapping_id'=>$d['mapping_id'],
'mapping_snapshot'=>json_encode($m,JSON_THROW_ON_ERROR),'mapping_hash'=>self::hash($m),'document'=>json_encode($doc,JSON_THROW_ON_ERROR),'reason'=>$d['reason'],
'source_version'=>$row->source_version+1,'phase'=>'draft','operation_key'=>null,'state_ciphertext'=>null,'approval_hash'=>null,'lease_id'=>null,'lease_until'=>null], 'draft.revised',$d['reason']);
});
}
public function row(User $actor,int $id,bool $locked=false):object
{
$q=DB::table('sgf_invoice_dispatches')->where('tenant_id',$actor->tenant_id)->where('id',$id);if($locked)$q->lockForUpdate();
$row=$q->first();abort_unless($row,404);return $row;
}
private function summary(object $row):array
{
$state=$this->state($row);
return ['id'=>$row->id,'reference'=>$row->reference,'number'=>$row->number,'version'=>(int)$row->version,'source_version'=>(int)$row->source_version,
'phase'=>$row->phase,'mapping_hash'=>$row->mapping_hash,'document_hash'=>self::hash(json_decode($row->document,true)),
'document'=>json_decode($row->document,true),'mapping_snapshot'=>json_decode($row->mapping_snapshot,true),'reason'=>$row->reason,
'operation_key'=>$row->operation_key,'approval_hash'=>$row->approval_hash,'ever_sending'=>(bool)$row->ever_sending,
'intent_hash'=>$state['intent']['intent_hash']??null,'impact_hash'=>isset($state['impact'])?self::hash($state['impact']):null,
'preview_actor_id'=>$row->preview_actor_id,'source_context_hash'=>$row->source_context_hash,
'receipt'=>$row->receipt?json_decode($row->receipt,true):null];
}
private function event(object $row,User $actor,string $action,string $reason,?array $before):void
{
DB::table('sgf_invoice_dispatch_events')->insert(['dispatch_id'=>$row->id,'tenant_id'=>$row->tenant_id,'version'=>$row->version,'actor_id'=>$actor->id,
'action'=>$action,'reason'=>$reason,'before_state'=>$before?json_encode($before,JSON_THROW_ON_ERROR):null,'after_state'=>json_encode($this->summary($row),JSON_THROW_ON_ERROR),'created_at'=>now('UTC')]);
}
private function update(object $row,User $actor,array $changes,string $action,string $reason):object
{
$before=$this->summary($row);$changes+=['version'=>$row->version+1,'updated_at'=>now('UTC')];
DB::table('sgf_invoice_dispatches')->where('id',$row->id)->where('tenant_id',$row->tenant_id)->update($changes);
$next=$this->row($actor,$row->id);$this->event($next,$actor,$action,$reason,$before);return $next;
}
private function state(object $row):?array{return $row->state_ciphertext?json_decode(Crypt::decryptString($row->state_ciphertext),true,128,JSON_THROW_ON_ERROR):null;}
private function contextHash(User $actor):string
{
return self::hash([$actor->tenant_id,request()->hasSession()?request()->session()->get('work_context'):null]);
}
public function dispatch(User $actor,int $id,int $version,string $action,string $reason,?string $approval):object
{
abort_unless(in_array($action,['preview','confirm','recover'],true),422);
abort_unless(config('sgf_invoice.enabled')===true,423,'Transporte desligado; o rascunho permanece conservado.');
Validator::make(['reason'=>$reason],['reason'=>'required|string|min:10|max:2000'])->validate();
// Commit the intent/decision/lease BEFORE network. Neither DB locks nor an HTTP retry may silently duplicate a decision.
[$row,$client,$state,$m,$lease]=DB::transaction(function()use($actor,$id,$version,$action,$reason,$approval){
$user=$this->actor($actor,true);$row=$this->row($user,$id,true);abort_unless((int)$row->version===$version,409,'Versão desactualizada.');
abort_unless(!$row->lease_until||now('UTC')->gte($row->lease_until),409,'Operação em curso; aguarde e consulte o estado.');
abort_if($row->phase==='received_pending',409,'Recibo já conservado.');$m=$this->checkSource($row,$user);
$client=app(SgfInvoiceGateway::class)->clientFor($user,$row->connection_id);$state=$this->state($row);
if($state){
$expected=$client->intent($m['mapping'],['reference'=>$row->reference,'version'=>(string)$row->source_version,'state'=>'unregistered_draft','document'=>json_decode($row->document,true)],$row->operation_key,$row->reason);
if(($state['intent']['intent_hash']??null)!==$expected['intent_hash']){
abort_unless($action==='preview',409,'Ligação ou contexto mudaram; exige nova conferência.');
$state=['intent'=>$expected,'phase'=>'draft'];
}
}
$lease=(string)Str::uuid();$changes=['lease_id'=>$lease,'lease_until'=>now('UTC')->addSeconds(120),'phase'=>$action==='preview'?'previewing':'sending'];
if($action==='preview'){
$changes+=['preview_actor_id'=>$user->id,'source_context_hash'=>$this->contextHash($user)];
if(!$state){$key=(string)Str::uuid();$intent=$client->intent($m['mapping'],['reference'=>$row->reference,'version'=>(string)$row->source_version,'state'=>'unregistered_draft','document'=>json_decode($row->document,true)],$key,$row->reason);$state=['intent'=>$intent,'phase'=>'draft'];$changes['operation_key']=$key;}
}elseif($action==='confirm'){
abort_unless($row->phase==='previewed'&&$state&&is_string($approval)&&hash_equals($state['approval_hash']??'',$approval),409,'Aprovar individualmente a prévia actual.');
abort_unless((int)$row->preview_actor_id===$user->id&&$row->source_context_hash===$this->contextHash($user),409,'Operador ou contexto mudaram; conferir novamente.');
$changes+=['ever_sending'=>true,'approval_hash'=>$approval];
}else{
abort_unless((bool)$row->ever_sending&&in_array($row->phase,['sending','uncertain','blocked','review_required','not_sent'],true)&&$state&&$row->approval_hash,409,'Sem confirmação anterior para recuperar.');
$state['phase']='uncertain';$approval=$row->approval_hash;
}
$changes['state_ciphertext']=Crypt::encryptString(json_encode($state,JSON_THROW_ON_ERROR));
$row=$this->update($row,$user,$changes,$action==='confirm'?'decision.confirmed':($action==='recover'?'recovery.started':'preview.started'),$reason);
return [$row,$client,$state,$m,$lease];
});
try{
if($action==='preview')$result=array_replace($state,$client->preview($state['intent'],(string)$row->source_version,\Tempo\SgcInvoiceClient\InvoiceClient::mappingHash($m['mapping'])));
else $result=$client->confirm($state,$action==='confirm'?$approval:$row->approval_hash,(string)$row->source_version,\Tempo\SgcInvoiceClient\InvoiceClient::mappingHash($m['mapping']));
}catch(\Throwable){$result=array_replace($state,['phase'=>$action==='preview'?'not_sent':'uncertain','error'=>'DELIVERY_NOT_CONFIRMED']);}
return DB::transaction(function()use($actor,$id,$lease,$result,$reason){
app(ConflictGuard::class)->lockCompany($actor);$current=$this->row($actor,$id,true);
abort_unless($current->lease_id===$lease,409,'Operação recuperada por outra sessão; actualizar.');
$allowed=['previewed','received_pending','uncertain','blocked','review_required','not_sent'];
$phase=in_array($result['phase']??null,$allowed,true)?$result['phase']:'uncertain';
return $this->update($current,$actor,['phase'=>$phase,'lease_id'=>null,'lease_until'=>null,
'state_ciphertext'=>Crypt::encryptString(json_encode($result,JSON_THROW_ON_ERROR)),
'receipt'=>isset($result['receipt'])?json_encode($result['receipt'],JSON_THROW_ON_ERROR):null], 'transport.observed',$reason);
});
}
public function presentation(User $actor,object $row):array
{
$this->actor($actor);$state=$this->state($row);$out=$this->summary($row);
return array_replace($out,['connection_id'=>$row->connection_id,'mapping_id'=>$row->mapping_id,'document'=>json_decode($row->document,true),'reason'=>$row->reason,
'impact'=>$state['impact']??null,'approval_hash'=>$row->phase==='previewed'?($state['approval_hash']??null):null,
'lease_until'=>$row->lease_until]);
}
public static function hash(mixed $v):string
{
$normalise=function($x)use(&$normalise){if(!is_array($x))return $x;if(!array_is_list($x))ksort($x,SORT_STRING);foreach($x as&$item)$item=$normalise($item);return $x;};
return hash('sha256',json_encode($normalise($v),JSON_THROW_ON_ERROR|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use LogicException;
/** Prepared gateway only; no event listener, route, scheduled job or automatic transmission. */
final class SgfInvoiceGateway
{
public function clientFor(User $actor,string $connection): \Tempo\SgcInvoiceClient\InvoiceClient
{
if(config('sgf_invoice.enabled')!==true)throw new LogicException('SGF invoice gateway disabled.');
$all=config('sgf_invoice.connections',[]);$c=$all[$connection]??null;
abort_unless(is_array($c)&&($c['enabled']??false)===true&&auth()->id()===$actor->id,403);
$current=User::where('tenant_id',$actor->tenant_id)->findOrFail($actor->id);
abort_unless(($c['source_scope']['tenant_id']??null)===$current->tenant_id&&$current->hasFunctions(['finance'])
&&!app(OrganizationAccess::class)->maintenanceOnly($current),403);
$path=config('sgf_invoice.client_package');$pin=config('sgf_invoice.client_package_sha256');
if(!is_string($path)||is_link($path)||!is_file($path)||!is_string($pin)||!preg_match('/^[a-f0-9]{64}$/D',$pin)
||!hash_equals($pin,hash_file('sha256',$path)))throw new LogicException('Verified client package required.');
if(class_exists(\Tempo\SgcInvoiceClient\InvoiceClient::class,false)){
if(!hash_equals($pin,hash_file('sha256',(new \ReflectionClass(\Tempo\SgcInvoiceClient\InvoiceClient::class))->getFileName())))throw new LogicException('Client version changed; restart separately.');
}else require_once $path;
$configurationHash=hash('sha256',json_encode($c,JSON_THROW_ON_ERROR));
return new \Tempo\SgcInvoiceClient\InvoiceClient($c['client_id'],$c['token'],$c['context_version'],$c['source_scope'],$c['target_scope'],
function(string $method,string $url,array $headers,?array $body)use($actor,$connection,$configurationHash):array{
// Recheck the originating person's authority immediately before every network operation.
$now=config('sgf_invoice.connections',[])[$connection]??null;
if(config('sgf_invoice.enabled')!==true||!is_array($now)||!hash_equals($configurationHash,hash('sha256',json_encode($now,JSON_THROW_ON_ERROR)))
||auth()->id()!==$actor->id)return ['status'=>403,'json'=>null];
$current=User::where('tenant_id',$actor->tenant_id)->find($actor->id);
if(!$current||!$current->hasFunctions(['finance'])||app(OrganizationAccess::class)->maintenanceOnly($current))return ['status'=>403,'json'=>null];
$http=Http::withHeaders($headers)->acceptJson()->asJson()->withoutRedirecting()->connectTimeout(3)->timeout(15);
$response=$method==='GET'?$http->get($url):$http->post($url,$body);
return ['status'=>$response->status(),'json'=>$response->json()];
},$c['origin']);
}
}