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
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]>
181 lines
6.4 KiB
PHP
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 );
|
|
}
|
|
}
|
|
}
|