Initialer Laravel Commit für BetiX
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-04-04 18:01:50 +02:00
commit 0280278978
374 changed files with 65210 additions and 0 deletions

View File

@@ -0,0 +1,213 @@
<?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);
}
}