<?php
declare(strict_types=1);
namespace App\Services\Fx;
use App\Entity\FxPolicyAudit;
use Doctrine\ORM\EntityManagerInterface;
/**
* Global FX safety policy: MARKET_MINUS_PERCENT.
*
* Adjusted rate = reference rate × (1 − percent/100).
* Default 5% → multiplier 0.95. Applied exactly once; never to same-currency 1:1.
*/
class FxSafetyBufferPolicy
{
public const POLICY_MARKET_MINUS_PERCENT = 'MARKET_MINUS_PERCENT';
private string $defaultPercent;
private ?EntityManagerInterface $entityManager;
/**
* @param int|float|string $bufferPercent Percent points, e.g. 5 for 5%.
*/
public function __construct($bufferPercent = 5, ?EntityManagerInterface $entityManager = null)
{
$this->defaultPercent = $this->normalizePercent($bufferPercent);
$this->entityManager = $entityManager;
}
public function getPolicyName(): string
{
return self::POLICY_MARKET_MINUS_PERCENT;
}
public function getBufferPercent(): string
{
$override = $this->latestOverridePercent();
return $override ?? $this->defaultPercent;
}
public function getConfiguredDefaultPercent(): string
{
return $this->defaultPercent;
}
/**
* Multiplier applied to the reference rate (0.95 when buffer is 5%).
*/
public function getMultiplier(): string
{
return bcsub('1', bcdiv($this->getBufferPercent(), '100', 12), 12);
}
/**
* Markup as a decimal fraction for FxConversion.markup (0.05 when buffer is 5%).
*/
public function getMarkupFraction(): string
{
return bcdiv($this->getBufferPercent(), '100', 8);
}
/**
* Apply the safety buffer to a reference/market rate exactly once.
* Do not pass an already-adjusted rate.
*/
public function adjustReferenceRate(string $referenceRate): string
{
$referenceRate = trim($referenceRate);
if ($referenceRate === '' || !is_numeric($referenceRate) || stripos($referenceRate, 'e') !== false) {
throw new \InvalidArgumentException(sprintf('Invalid reference rate: "%s"', $referenceRate));
}
if (bccomp($referenceRate, '0', 12) <= 0) {
throw new \InvalidArgumentException('Reference rate must be greater than zero');
}
if (bccomp($this->getBufferPercent(), '0', 4) === 0) {
return $referenceRate;
}
return bcmul($referenceRate, $this->getMultiplier(), 12);
}
public function withBufferPercent($bufferPercent): self
{
return new self($bufferPercent, $this->entityManager);
}
/**
* @return array{
* policy: string,
* safety_buffer_percent: string,
* configured_default_percent: string,
* multiplier: string,
* calculation: string
* }
*/
public function describe(): array
{
$multiplier = $this->getMultiplier();
return [
'policy' => self::POLICY_MARKET_MINUS_PERCENT,
'safety_buffer_percent' => $this->getBufferPercent(),
'configured_default_percent' => $this->defaultPercent,
'multiplier' => $multiplier,
'calculation' => sprintf(
'Reference Rate × %s%%',
rtrim(rtrim(bcmul($multiplier, '100', 4), '0'), '.')
),
];
}
private function latestOverridePercent(): ?string
{
if ($this->entityManager === null) {
return null;
}
try {
$latest = $this->entityManager->getRepository(FxPolicyAudit::class)->findOneBy([], ['id' => 'DESC']);
} catch (\Throwable $e) {
return null;
}
if (!$latest instanceof FxPolicyAudit) {
return null;
}
return $this->normalizePercent($latest->getToPercent());
}
/**
* @param int|float|string $value
*/
private function normalizePercent($value): string
{
$raw = trim((string) $value);
if ($raw === '' || !is_numeric($raw) || stripos($raw, 'e') !== false) {
throw new \InvalidArgumentException(sprintf('Invalid FX safety buffer percent: "%s"', $raw));
}
$normalized = bcadd($raw, '0', 4);
if (bccomp($normalized, '0', 4) < 0 || bccomp($normalized, '50', 4) > 0) {
throw new \InvalidArgumentException('FX safety buffer percent must be between 0 and 50');
}
return $normalized;
}
}