Harden published curriculum and student flows

This commit is contained in:
Hamza-Ayed
2026-09-30 08:22:17 +03:00
parent e8163fe265
commit ce7b0fcf14
53 changed files with 4723 additions and 3317 deletions
+124 -29
View File
@@ -1,40 +1,135 @@
<?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 {
$account=self::account($teacherId);
$row=Database::selectOne('SELECT COALESCE(SUM(CASE WHEN credit_account_id=? THEN amount_fils WHEN debit_account_id=? THEN -amount_fils ELSE 0 END),0) AS balance FROM ledger_entries WHERE credit_account_id=? OR debit_account_id=?',[$account,$account,$account,$account]);
$holds=Database::selectOne("SELECT COALESCE(SUM(amount_fils),0) AS held FROM teacher_withdrawal_holds WHERE teacher_id=? AND status='held'",[$teacherId]);
$gross=(int)($row['balance']??0);$held=(int)($holds['held']??0);
return ['account_id'=>$account,'gross_fils'=>$gross,'held_fils'=>$held,'available_fils'=>max(0,$gross-$held)];
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($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 {
$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 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 {
if(!self::isUuid($holdUuid)||trim($reason)==='')return ['http_status'=>400,'status'=>'error','message'=>'بيانات إلغاء الحجز غير صالحة.'];
$changed=Database::execute("UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW() WHERE uuid=? AND teacher_id=? AND status='held'",[$holdUuid,$teacherId]);
return $changed===1?['http_status'=>200,'status'=>'released','reason'=>$reason]:['http_status'=>409,'status'=>'hold_not_releasable','message'=>'الحجز غير موجود أو تمت تسويته.'];
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 account(int $teacherId):int {
$existing=Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId]);
if($existing)return(int)$existing['id'];
Database::insert("INSERT INTO ledger_accounts (code,owner_type,owner_id,currency) VALUES ('teacher_available','teacher',?,'JOD')",[$teacherId]);
return(int)(Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId])['id']??0);
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;
}
private static function isUuid(string $v):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',$v);}
private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));}
private static function finish(\PDO $pdo,array $result):array{$pdo->commit();return $result;}
}