Harden published curriculum and student flows
This commit is contained in:
@@ -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;}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user