Files
unsupervised-scheduler/src/Auth/StudentHistory.php
T
thatguygriffandClaude Opus 4.8 e8e66eef3c
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m52s
Credit students for cancelled paid lessons
Cancelling a lesson that was already paid for now credits the student
that money instead of leaving it as a manual refund, and the daily
scheduled-billing scan applies any available credit against their due
charges before emailing the notice.

- New us_credits ledger + us_payments.credit_applied column (Payment::netDue).
- PaymentService::creditForCancelledLesson issues a per-lesson share of the
  covering payment's total; wired into all three cancel paths (student
  self-cancel, instructor status update, admin student-detail cancel).
- PaymentService::applyCredits draws credit down FIFO across a run's charges,
  marking a fully-covered charge paid-by-credit; the notice shows the credit
  applied and reduced total, and the admin queue shows net due.
- Student detail page shows a student's credit balance and history.

Ships as part of the unreleased 1.2.0 (same release as scheduled billing).

Tests: composer test (585), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 15:32:20 -03:00

181 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Payment\Credit;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionRepository;
/**
* Builds the display rows for the history sections of the admin student detail
* view: policy acceptances, intake answers, and payments.
*/
class StudentHistory {
public function __construct(
private AcceptanceRepository $acceptances,
private PolicyRepository $policies,
private PolicyVersionRepository $policyVersions,
private AnswerRepository $answers,
private QuestionRepository $questions,
private PaymentRepository $payments,
private CreditRepository $credits,
) {}
/**
* Every policy acceptance the student has recorded, newest first.
*
* @return list<array{policy: string, version: string, context: string, accepted_at: string}>
*/
public function policyAcceptances( int $studentId ): array {
return array_map(
function ( PolicyAcceptance $acceptance ): array {
$version = $this->policyVersions->findById( $acceptance->policyVersionId );
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
return [
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
'context' => $this->contextLabel( $acceptance->registrationType, $acceptance->registrationId ),
'accepted_at' => $acceptance->acceptedAt ?? '',
];
},
$this->acceptances->findByStudent( $studentId )
);
}
/**
* Booking/enrolment intake answers the student has submitted, newest first.
* Account-signup answers are excluded — those are shown on their own under
* {@see registrationInfo()}.
*
* @return list<array{question: string, answer: string, context: string}>
*/
public function intakeAnswers( int $studentId ): array {
$bookingAnswers = array_filter(
$this->answers->findByStudent( $studentId ),
static fn( Answer $answer ): bool => Answer::REG_ACCOUNT !== $answer->registrationType
);
return array_values(
array_map(
function ( Answer $answer ): array {
$question = $this->questions->findById( $answer->questionId );
return [
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
'answer' => $answer->answerValue ?? '—',
'context' => $this->contextLabel( $answer->registrationType, $answer->registrationId ),
];
},
$bookingAnswers
)
);
}
/**
* The student's answers to the studio-wide account-signup questions: every
* configured account question paired with the student's answer ("—" when
* unanswered, e.g. a question added after they registered).
*
* @return list<array{question: string, answer: string, required: bool}>
*/
public function registrationInfo( int $studentId ): array {
$byQuestion = [];
foreach ( $this->answers->findByRegistration( Answer::REG_ACCOUNT, $studentId ) as $answer ) {
$byQuestion[ $answer->questionId ] = $answer->answerValue ?? '';
}
return array_map(
static function ( Question $question ) use ( $byQuestion ): array {
$value = $byQuestion[ (int) $question->id ] ?? '';
return [
'question' => $question->label,
'answer' => '' === $value ? '—' : $value,
'required' => $question->isRequired,
];
},
$this->questions->findByScope( Question::SCOPE_ACCOUNT )
);
}
/**
* Every payment for the student, newest first.
*
* @return list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}>
*/
public function payments( int $studentId ): array {
return array_map(
fn( Payment $payment ): array => [
'created_at' => $payment->createdAt ?? '',
'context' => $this->contextLabel( $payment->registrationType, $payment->registrationId ),
'method' => $payment->method,
'status' => $payment->status,
'amount' => $payment->amount,
'tax_amount' => $payment->taxAmount,
'total' => $payment->total(),
'currency' => $payment->currency,
'receipt' => $payment->receiptNumber ?? '—',
],
$this->payments->findByStudent( $studentId )
);
}
/**
* The student's total unused credit balance (from cancelled paid lessons),
* applied automatically against future scheduled-billing charges.
*/
public function creditBalance( int $studentId ): float {
return $this->credits->availableBalance( $studentId );
}
/**
* Every credit the student has been issued, newest first, with the amount, what
* remains, and its state.
*
* @return list<array{created_at: string, amount: float, remaining: float, currency: string, reason: string, status: string}>
*/
public function credits( int $studentId ): array {
return array_map(
static fn( Credit $credit ): array => [
'created_at' => $credit->createdAt ?? '',
'amount' => $credit->amount,
'remaining' => $credit->remaining,
'currency' => $credit->currency,
'reason' => $credit->reason ?? '—',
'status' => $credit->status,
],
$this->credits->findByStudent( $studentId )
);
}
/**
* Human label for a polymorphic registration target.
*/
private function contextLabel( string $registrationType, int $registrationId ): string {
switch ( $registrationType ) {
case PolicyAcceptance::REG_ACCOUNT:
return __( 'Account signup', 'unsupervised-schedular' );
case PolicyAcceptance::REG_LESSON:
/* translators: %d: the lesson id */
return sprintf( __( 'Lesson #%d', 'unsupervised-schedular' ), $registrationId );
case PolicyAcceptance::REG_ENROLLMENT:
/* translators: %d: the group-class enrolment id */
return sprintf( __( 'Enrolment #%d', 'unsupervised-schedular' ), $registrationId );
default:
return sprintf( '%s #%d', $registrationType, $registrationId );
}
}
}