136 lines
5.8 KiB
PHP
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;
|
|
}
|
|
}
|