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); } }