Files
saqel/backend/app/Services/TeacherLedgerService.php
T

136 lines
5.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Core\Database;
use PDO;
/** Integer-fils ledger. Credits are never inferred from UI, courses, or watch time. */
final class TeacherLedgerService
{
public static function balance(int $teacherId): array
{
// Balance reads must never create accounts or mutate financial state.
$accountId = self::existingAccountId($teacherId);
$gross = 0;
if ($accountId !== null) {
$row = Database::selectOne(
'SELECT COALESCE(SUM(CASE WHEN credit_account_id=? THEN CAST(amount_fils AS SIGNED) WHEN debit_account_id=? THEN -CAST(amount_fils AS SIGNED) ELSE 0 END),0) AS balance
FROM ledger_entries WHERE credit_account_id=? OR debit_account_id=?',
[$accountId, $accountId, $accountId, $accountId]
);
$gross = (int)($row['balance'] ?? 0);
}
$holds = Database::selectOne(
"SELECT COALESCE(SUM(amount_fils),0) AS held FROM teacher_withdrawal_holds WHERE teacher_id=? AND status='held'",
[$teacherId]
);
$held = (int)($holds['held'] ?? 0);
return [
'account_id' => $accountId,
'gross_fils' => $gross,
'held_fils' => $held,
'available_fils' => max(0, $gross - $held),
];
}
public static function placeWithdrawalHold(int $teacherId, int $amountFils, string $key): array
{
if ($teacherId <= 0 || $amountFils <= 0 || $amountFils > 100000000
|| !preg_match('/^[A-Za-z0-9._:-]{16,128}$/', $key)) {
return ['http_status' => 400, 'status' => 'error', 'message' => 'قيمة الحجز أو مفتاح الإعادة غير صالح.'];
}
$pdo = Database::getConnection();
$pdo->beginTransaction();
try {
// This existing row serializes all holds for one teacher. Lock it
// before the first consistent read, so the next hold sees this one.
$teacher = Database::selectOne('SELECT id FROM teachers WHERE id=? FOR UPDATE', [$teacherId]);
if (!$teacher) {
return self::finish($pdo, ['http_status' => 404, 'status' => 'teacher_not_found']);
}
$existing = Database::selectOne(
'SELECT uuid, amount_fils, status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE',
[$teacherId, $key]
);
if ($existing) {
if ((int)$existing['amount_fils'] !== $amountFils) {
return self::finish($pdo, [
'http_status' => 409, 'status' => 'idempotency_conflict',
'message' => 'استعمل المفتاح ذاته بمبلغ مختلف.',
]);
}
return self::finish($pdo, [
'http_status' => 200, 'status' => $existing['status'],
'withdrawal_hold_id' => $existing['uuid'], 'replayed' => true,
]);
}
$balance = self::balance($teacherId);
if ($amountFils > $balance['available_fils']) {
return self::finish($pdo, [
'http_status' => 409, 'status' => 'insufficient_available_balance',
'message' => 'الرصيد المتاح لا يغطي الحجز.',
'available_fils' => $balance['available_fils'],
]);
}
$uuid = self::uuid();
Database::insert(
"INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)",
[$uuid, $teacherId, $amountFils, $key]
);
return self::finish($pdo, [
'http_status' => 201, 'status' => 'held',
'withdrawal_hold_id' => $uuid, 'amount_fils' => $amountFils,
]);
} catch (\Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
public static function releaseHold(int $teacherId, string $holdUuid, string $reason): array
{
$reason = trim($reason);
if ($teacherId <= 0 || !self::isUuid($holdUuid) || $reason === '' || mb_strlen($reason) > 500) {
return ['http_status' => 400, 'status' => 'error', 'message' => 'بيانات إلغاء الحجز غير صالحة.'];
}
$changed = Database::execute(
"UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW(),release_reason=? WHERE uuid=? AND teacher_id=? AND status='held'",
[$reason, $holdUuid, $teacherId]
);
return $changed === 1
? ['http_status' => 200, 'status' => 'released', 'reason' => $reason]
: ['http_status' => 409, 'status' => 'hold_not_releasable', 'message' => 'الحجز غير موجود أو تمت تسويته.'];
}
private static function existingAccountId(int $teacherId): ?int
{
$row = Database::selectOne(
"SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",
[$teacherId]
);
return $row ? (int)$row['id'] : null;
}
private static function isUuid(string $value): bool
{
return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value);
}
private static function uuid(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 15) | 64);
$bytes[8] = chr((ord($bytes[8]) & 63) | 128);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
private static function finish(PDO $pdo, array $result): array
{
$pdo->commit();
return $result;
}
}