environment(['testing', 'payable-testing']) && DB::connection()->getDriverName() === 'pgsql' && (bool) preg_match('/^compras_receipts_test_[a-f0-9]{12}$/D', DB::connection()->getDatabaseName()); return ($isolated || config('common-payables.enabled') === true) && is_string(config('common-payables.source_instance')) && trim(config('common-payables.source_instance')) !== '' && Schema::hasTable('pa_obligations') && Schema::hasTable('pa_access'); } /** @return array */ private function scope(User $u): array { return ['tenant_id' => (int) $u->tenant_id, 'company_id' => 0, 'unit_id' => 0]; } public function can(User $u, string $action): bool { if (app(OrganizationAccess::class)->active($u->tenant_id) && ! $u->hasFunctions([PurchasingRole::Admin, PurchasingRole::Finance])) { return false; } return self::enabled() && DB::table('pa_access')->where($this->scope($u))->where(['user_id' => $u->id, 'action' => $action])->exists(); } public static function historyEnabled(): bool { return self::enabled() && config('common-payables.history_enabled') === true && Schema::hasTable('pa_history_imports') && Schema::hasTable('pa_imported_payments'); } public function assertHistoricalReview(User $user, Invoice $invoice): bool { if ($this->principal($invoice) === null) { return false; } abort_unless(self::historyDecisionsEnabled() && $this->can($user, 'validate'), 403, 'Conferência de pendentes importados não activada.'); return true; } public static function historyDecisionsEnabled(): bool { return self::historyEnabled() && config('common-payables.history_decisions_enabled') === true && Schema::hasTable('pa_payment_decisions'); } /** * Original payment facts only; no encrypted references or bank data. * * @return list> */ private function paymentHistory(User $user, int $invoiceId): array { $result = []; $payments = DB::table('external_payments')->where(['tenant_id' => $user->tenant_id, 'invoice_id' => $invoiceId])->orderBy('id')->get(); foreach ($payments as $payment) { $audit = fn (string $action, int $actor, string $status) => DB::table('audit_logs')->where('tenant_id', $user->tenant_id) ->where('action', $action)->where('user_id', $actor)->where('after->payment_id', $payment->id) ->where('after->invoice_id', $invoiceId)->where('after->amount_minor', $payment->amount_minor)->where('after->status', $status)->orderByDesc('id')->first(['id']); $registration = $audit('payment.registered', (int) $payment->created_by, 'pending'); $decision = $payment->status === 'pending' ? null : $audit('payment.'.$payment->status, (int) $payment->reviewed_by, $payment->status); if (! $registration || ($payment->status !== 'pending' && ! $decision)) { throw new \DomainException('Histórico sem auditoria compatível; preservar os originais para conferência.'); } $result[] = ['id' => (int) $payment->id, 'company' => (int) $payment->tenant_id, 'document' => (int) $payment->invoice_id, 'amount_minor' => (int) $payment->amount_minor, 'currency' => $payment->currency, 'status' => $payment->status, 'created_by' => (int) $payment->created_by, 'paid_on' => $payment->paid_on, 'registration_audit_id' => (int) $registration->id, 'reviewed_by' => $payment->reviewed_by === null ? null : (int) $payment->reviewed_by, 'reviewed_at' => $payment->reviewed_at, 'decision_audit_id' => $decision ? (int) $decision->id : null]; } return $result; } /** @return array */ public function document(User $u, int $id): array { abort_unless(self::enabled() && auth()->id() === $u->id, 403); $invoice = Invoice::query()->where('tenant_id', $u->tenant_id)->findOrFail($id); $order = DB::table('purchase_orders')->where(['id' => $invoice->purchase_order_id, 'tenant_id' => $u->tenant_id])->first(); $supplier = DB::table('suppliers')->where(['id' => $invoice->supplier_id, 'tenant_id' => $u->tenant_id])->first(); $audit = DB::table('audit_logs')->where('tenant_id', $u->tenant_id)->where('action', 'invoice.validated')->where('after->invoice_id', $id)->orderByDesc('id')->first(); return ['local_id' => $id, 'instance' => config('common-payables.source_instance'), 'company' => (int) $u->tenant_id, 'document' => $id, 'number' => $invoice->number, 'supplier_id' => $supplier?->id, 'supplier_name' => $invoice->snapshot['supplier']['name'] ?? null, 'order_id' => $order?->id, 'currency' => $invoice->snapshot['currency'] ?? null, 'issued_on' => $invoice->issued_on, 'subtotal_minor' => (int) $invoice->subtotal_minor, 'tax_minor' => (int) $invoice->tax_minor, 'total_minor' => (int) $invoice->total_minor, 'status' => $invoice->status, 'validated_by' => $invoice->validated_by, 'validated_at' => $invoice->validated_at, 'validation_audit_id' => $audit?->id, 'lines' => $invoice->items()->orderBy('id')->get()->map(fn ($l) => ['id' => $l->id, 'order_line' => $l->purchase_order_item_id, 'quantity_milli' => (int) $l->quantity_milli, 'subtotal_minor' => (int) $l->subtotal_minor, 'tax_minor' => (int) $l->tax_minor, 'snapshot' => $l->snapshot])->all(), 'payment_count' => DB::table('external_payments')->where(['tenant_id' => $u->tenant_id, 'invoice_id' => $id])->count()]; } public function engine(User $u): Payables { abort_unless(self::enabled() && auth()->id() === $u->id && $u->tenant_id !== null, 403, 'Obrigações não activadas nesta instalação ou acesso indisponível.'); $s = $this->scope($u); $person = DB::table('pa_access')->where($s)->where(['user_id' => $u->id, 'action' => 'view'])->value('person'); abort_unless($person, 403); return new Payables(DB::connection(), $u->id, $person, $s, function (string $action) use ($u, $s, $person) { abort_unless(auth()->id() === $u->id, 403); app(ConflictGuard::class)->lockCompany($u); app(CurrentActor::class)->lock($u, [PurchasingRole::Finance, PurchasingRole::Admin]); abort_unless(DB::table('pa_access')->where($s)->where(['user_id' => $u->id, 'action' => 'view'])->value('person') === $person, 409, 'Identidade de pessoa alterada; reabra a proposta.'); abort_unless($this->can($u, 'view') && $this->can($u, $action), 403); }, fn (int $id) => $this->document($u, $id), self::historyEnabled() ? fn (int $id) => $this->paymentHistory($u, $id) : null); } /** @return array */ public function listing(User $u, ?int $invoiceId = null): array { $engine = $this->engine($u); $d = $engine->listing(Invoice::query()->where('tenant_id', $u->tenant_id)->pluck('id')->all()); $d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')]; $d['payments_available'] = app()->environment('payable-testing') && Schema::hasTable('ps_payments') && $this->can($u, 'payments_view'); $d['history_import_available'] = self::historyEnabled(); $d['history_decisions_available'] = self::historyDecisionsEnabled(); if ($invoiceId !== null) { $invoice = Invoice::query()->where('tenant_id', $u->tenant_id)->findOrFail($invoiceId); $document = null; foreach ($d['documents'] as $candidate) { if ((int) $candidate['local_id'] === $invoice->id) { $document = $candidate; break; } } abort_unless($document, 404); $sourceIds = DB::table('pa_sources')->where($this->scope($u)) ->where('source_key', Payables::identity($document))->pluck('id')->map(fn ($id) => (int) $id)->all(); $d['documents'] = [$document]; $d['proposals'] = $d['proposals']->filter(fn ($p) => in_array((int) $p->source_id, $sourceIds, true))->values(); $d['obligations'] = array_values(array_filter($d['obligations'], fn ($o) => in_array((int) $o['source_id'], $sourceIds, true))); $d['events'] = $d['events']->filter(fn ($event) => in_array((int) $event->source_id, $sourceIds, true))->values(); } return $d; } public function principal(Invoice $invoice): ?int { $position = $this->recognisedPosition($invoice); return $position ? (int) $position['principal_minor'] : null; } public function balance(Invoice $invoice): ?int { $position = $this->recognisedPosition($invoice); return $position ? (int) $position['balance_minor'] : null; } /** @return array|null */ private function recognisedPosition(Invoice $invoice): ?array { if (! self::enabled()) { if (Schema::hasTable('pa_obligations') && Schema::hasTable('pa_sources')) { abort_if(DB::table('pa_sources as s')->join('pa_obligations as o', 'o.source_id', '=', 's.id') ->where('s.tenant_id', $invoice->tenant_id)->where('s.company_id', 0)->where('s.unit_id', 0) ->where('s.local_source_id', $invoice->id)->exists(), 409, 'Núcleo desactivado com obrigação incorporada; saldo exige transição explícita.'); } return null; } $u = auth()->user(); abort_unless($u instanceof User, 403); abort_unless((int) $invoice->tenant_id === (int) $u->tenant_id, 403); $s = $this->scope($u); $identity = Payables::identity(['instance' => config('common-payables.source_instance'), 'company' => (int) $invoice->tenant_id, 'document' => $invoice->id]); $row = DB::table('pa_sources as s')->join('pa_obligations as o', 'o.source_id', '=', 's.id')->where('s.tenant_id', $s['tenant_id'])->where('s.company_id', 0)->where('s.unit_id', 0)->where('s.source_key', $identity)->where('s.local_source_id', $invoice->id)->select('o.principal_minor', 'o.source_revision')->first(); if (! $row) { abort_if(DB::table('pa_sources as s')->join('pa_obligations as o', 'o.source_id', '=', 's.id') ->where('s.tenant_id', $s['tenant_id'])->where('s.company_id', 0)->where('s.unit_id', 0) ->where('s.local_source_id', $invoice->id)->exists(), 409, 'Identidade da instalação alterada; reconciliar origem antes de calcular saldo.'); return null; } abort_unless($this->can($u, 'view'), 403); try { $position = $this->engine($u)->positionForSource($invoice->id); } catch (\DomainException $e) { abort(409, $e->getMessage()); } abort_unless($position && ! $position['source_conflict'] && $position['balance_minor'] !== null, 409, 'Origem ou histórico alterado; principal preservado e saldo pendente de revisão.'); return $position; } }