214 lines
8.4 KiB
PHP
214 lines
8.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use App\Models\VaultTransfer;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class VaultService
|
|
{
|
|
private int $scale = 4;
|
|
|
|
public function getScale(): int { return $this->scale; }
|
|
|
|
public function depositToVault(User $user, string|float $amount, ?string $idempotencyKey = null, string $source = 'web', ?int $createdBy = null, array $metadata = []): VaultTransfer
|
|
{
|
|
return $this->move($user, $amount, 'to_vault', $idempotencyKey, $source, $createdBy, $metadata);
|
|
}
|
|
|
|
public function withdrawFromVault(User $user, string|float $amount, ?string $idempotencyKey = null, string $source = 'web', ?int $createdBy = null, array $metadata = []): VaultTransfer
|
|
{
|
|
return $this->move($user, $amount, 'from_vault', $idempotencyKey, $source, $createdBy, $metadata);
|
|
}
|
|
|
|
private function move(User $user, string|float $amount, string $direction, ?string $idempotencyKey, string $source, ?int $createdBy, array $metadata): VaultTransfer
|
|
{
|
|
$amountStr = $this->normalizeAmount($amount);
|
|
if ($this->cmp($amountStr, '0.0000') <= 0) {
|
|
throw ValidationException::withMessages(['amount' => 'Amount must be greater than 0.']);
|
|
}
|
|
|
|
if ($idempotencyKey) {
|
|
$existing = VaultTransfer::where('user_id', $user->id)
|
|
->where('idempotency_key', $idempotencyKey)
|
|
->first();
|
|
if ($existing) {
|
|
// If direction/amount mismatch, reject to avoid accidental replay with different params
|
|
if ($existing->direction !== $direction || $this->cmp($existing->amount, $amountStr) !== 0) {
|
|
throw ValidationException::withMessages(['idempotency_key' => 'Conflicting idempotent request.'])->status(409);
|
|
}
|
|
return $existing;
|
|
}
|
|
}
|
|
|
|
return DB::transaction(function () use ($user, $amountStr, $direction, $idempotencyKey, $source, $createdBy, $metadata) {
|
|
// Lock user row
|
|
$u = User::whereKey($user->id)->lockForUpdate()->first();
|
|
$mainBefore = $this->normalizeAmount($u->balance);
|
|
$vaultBefore = $this->normalizeAmount($u->vault_balance ?? '0');
|
|
|
|
if ($direction === 'to_vault') {
|
|
// move from main to vault
|
|
if ($this->cmp($mainBefore, $amountStr) < 0) {
|
|
throw ValidationException::withMessages(['amount' => 'Insufficient main balance.']);
|
|
}
|
|
$mainAfter = $this->sub($mainBefore, $amountStr);
|
|
$vaultAfter = $this->add($vaultBefore, $amountStr);
|
|
} else { // from_vault
|
|
if ($this->cmp($vaultBefore, $amountStr) < 0) {
|
|
throw ValidationException::withMessages(['amount' => 'Insufficient vault balance.']);
|
|
}
|
|
$mainAfter = $this->add($mainBefore, $amountStr);
|
|
$vaultAfter = $this->sub($vaultBefore, $amountStr);
|
|
}
|
|
|
|
// Persist balances
|
|
$u->balance = $mainAfter; // existing cast is decimal:4
|
|
$u->vault_balance = $vaultAfter; // encrypted decimal cast
|
|
$u->save();
|
|
|
|
$transfer = VaultTransfer::create([
|
|
'user_id' => $u->id,
|
|
'direction' => $direction,
|
|
'amount' => $amountStr,
|
|
'main_balance_before' => $mainBefore,
|
|
'main_balance_after' => $mainAfter,
|
|
'vault_balance_before' => $vaultBefore,
|
|
'vault_balance_after' => $vaultAfter,
|
|
'idempotency_key' => $idempotencyKey,
|
|
'source' => $source,
|
|
'created_by' => $createdBy,
|
|
'metadata' => $metadata,
|
|
]);
|
|
|
|
return $transfer;
|
|
});
|
|
}
|
|
|
|
// ---- Decimal helpers using integer arithmetic with fixed scale ----
|
|
private function normalizeAmount(string|float|int $v): string
|
|
{
|
|
$s = is_string($v) ? trim($v) : (string) $v;
|
|
$s = str_replace([',', ' '], ['', ''], $s);
|
|
if ($s === '' || $s === '-') $s = '0';
|
|
if (!preg_match('/^-?\d*(?:\.\d+)?$/', $s)) {
|
|
throw ValidationException::withMessages(['amount' => 'Invalid amount format.']);
|
|
}
|
|
$neg = str_starts_with($s, '-') ? '-' : '';
|
|
if ($neg) $s = substr($s, 1);
|
|
[$int, $frac] = array_pad(explode('.', $s, 2), 2, '');
|
|
$frac = substr($frac . str_repeat('0', $this->scale), 0, $this->scale);
|
|
$int = ltrim($int, '0');
|
|
if ($int === '') $int = '0';
|
|
return ($neg ? '-' : '') . $int . ($this->scale > 0 ? ('.' . $frac) : '');
|
|
}
|
|
|
|
private function toScaledInt(string $s): array
|
|
{
|
|
$neg = str_starts_with($s, '-') ? -1 : 1;
|
|
if ($neg < 0) $s = substr($s, 1);
|
|
[$int, $frac] = array_pad(explode('.', $s, 2), 2, '');
|
|
$frac = substr($frac . str_repeat('0', $this->scale), 0, $this->scale);
|
|
$num = ltrim($int, '0');
|
|
if ($num === '') $num = '0';
|
|
$num .= $frac;
|
|
return [$neg, ltrim($num, '0') === '' ? '0' : ltrim($num, '0')];
|
|
}
|
|
|
|
private function fromScaledInt(int $sign, string $digits): string
|
|
{
|
|
// pad left to ensure at least scale digits
|
|
$digits = ltrim($digits, '0');
|
|
if ($digits === '') $digits = '0';
|
|
$pad = max(0, $this->scale - strlen($digits));
|
|
$digits = str_repeat('0', $pad) . $digits;
|
|
$int = substr($digits, 0, strlen($digits) - $this->scale);
|
|
$frac = substr($digits, -$this->scale);
|
|
if ($int === '') $int = '0';
|
|
$out = $int;
|
|
if ($this->scale > 0) $out .= '.' . $frac;
|
|
if ($sign < 0 && $out !== '0' . ($this->scale > 0 ? '.' . str_repeat('0', $this->scale) : '')) $out = '-' . $out;
|
|
return $out;
|
|
}
|
|
|
|
private function add(string $a, string $b): string
|
|
{
|
|
[$sa, $ia] = $this->toScaledInt($a);
|
|
[$sb, $ib] = $this->toScaledInt($b);
|
|
if ($sa === $sb) {
|
|
$sum = $this->addDigits($ia, $ib);
|
|
return $this->fromScaledInt($sa, $sum);
|
|
}
|
|
// different signs -> subtraction
|
|
if ($this->cmpAbs($ia, $ib) >= 0) {
|
|
$diff = $this->subDigits($ia, $ib);
|
|
return $this->fromScaledInt($sa, $diff);
|
|
} else {
|
|
$diff = $this->subDigits($ib, $ia);
|
|
return $this->fromScaledInt($sb, $diff);
|
|
}
|
|
}
|
|
|
|
private function sub(string $a, string $b): string
|
|
{
|
|
// a - b = a + (-b)
|
|
$negB = str_starts_with($b, '-') ? substr($b, 1) : ('-' . $b);
|
|
return $this->add($a, $negB);
|
|
}
|
|
|
|
private function cmp(string $a, string $b): int
|
|
{
|
|
[$sa, $ia] = $this->toScaledInt($a);
|
|
[$sb, $ib] = $this->toScaledInt($b);
|
|
if ($sa !== $sb) return $sa <=> $sb; // -1 vs 1
|
|
$c = $this->cmpAbs($ia, $ib);
|
|
return $sa * $c;
|
|
}
|
|
|
|
private function addDigits(string $x, string $y): string
|
|
{
|
|
$cx = strrev($x); $cy = strrev($y);
|
|
$len = max(strlen($cx), strlen($cy));
|
|
$carry = 0; $out = '';
|
|
for ($i = 0; $i < $len; $i++) {
|
|
$dx = $i < strlen($cx) ? ord($cx[$i]) - 48 : 0;
|
|
$dy = $i < strlen($cy) ? ord($cy[$i]) - 48 : 0;
|
|
$s = $dx + $dy + $carry;
|
|
$out .= chr(($s % 10) + 48);
|
|
$carry = intdiv($s, 10);
|
|
}
|
|
if ($carry) $out .= chr($carry + 48);
|
|
return strrev($out);
|
|
}
|
|
|
|
private function subDigits(string $x, string $y): string
|
|
{
|
|
// assumes x >= y in absolute
|
|
$cx = strrev($x); $cy = strrev($y);
|
|
$len = max(strlen($cx), strlen($cy));
|
|
$borrow = 0; $out = '';
|
|
for ($i = 0; $i < $len; $i++) {
|
|
$dx = $i < strlen($cx) ? ord($cx[$i]) - 48 : 0;
|
|
$dy = $i < strlen($cy) ? ord($cy[$i]) - 48 : 0;
|
|
$d = $dx - $borrow - $dy;
|
|
if ($d < 0) { $d += 10; $borrow = 1; } else { $borrow = 0; }
|
|
$out .= chr($d + 48);
|
|
}
|
|
// reverse back to normal order and normalize without stripping valid trailing zeros
|
|
$out = strrev($out);
|
|
// remove leading zeros only; keep at least one zero
|
|
$out = ltrim($out, '0');
|
|
return $out === '' ? '0' : $out;
|
|
}
|
|
|
|
private function cmpAbs(string $x, string $y): int
|
|
{
|
|
$x = ltrim($x, '0'); if ($x === '') $x = '0';
|
|
$y = ltrim($y, '0'); if ($y === '') $y = '0';
|
|
if (strlen($x) !== strlen($y)) return strlen($x) <=> strlen($y);
|
|
return strcmp($x, $y);
|
|
}
|
|
}
|