Student detail: history sections and admin actions (cancel, withdraw, edit account) #75
@@ -10,6 +10,11 @@ No new tables. The views are composed from existing data:
|
|||||||
- Students are WordPress users with the `us_student` role (`get_users`, `get_userdata`).
|
- Students are WordPress users with the `us_student` role (`get_users`, `get_userdata`).
|
||||||
- Lessons come from `{prefix}us_lessons` (with `{prefix}us_availability` for slot times).
|
- Lessons come from `{prefix}us_lessons` (with `{prefix}us_availability` for slot times).
|
||||||
- Group-class enrolments come from `{prefix}us_group_enrollments`.
|
- Group-class enrolments come from `{prefix}us_group_enrollments`.
|
||||||
|
- Policy acceptances come from `{prefix}us_policy_acceptances` (with the policy
|
||||||
|
and version tables for titles/numbers).
|
||||||
|
- Intake answers come from `{prefix}us_question_answers` (with `{prefix}us_questions`
|
||||||
|
for labels).
|
||||||
|
- Payments come from `{prefix}us_payments`.
|
||||||
|
|
||||||
## Admin Interface
|
## Admin Interface
|
||||||
**Students** in wp-admin (`manage_students`, studio admin only):
|
**Students** in wp-admin (`manage_students`, studio admin only):
|
||||||
@@ -22,10 +27,15 @@ No new tables. The views are composed from existing data:
|
|||||||
- **Upcoming lessons** and **Past lessons** — split by the linked availability
|
- **Upcoming lessons** and **Past lessons** — split by the linked availability
|
||||||
slot's `start_dt`; each shows date/time, offering, instructor, and status.
|
slot's `start_dt`; each shows date/time, offering, instructor, and status.
|
||||||
- **Group-class enrolments** — active/past, with offering title and status.
|
- **Group-class enrolments** — active/past, with offering title and status.
|
||||||
- *(Later)* policy-acceptance history, intake answers, and payment history once
|
- **Policy acceptances** — every acceptance the student has recorded, newest
|
||||||
Payments lands.
|
first: policy title, version, context (account signup / lesson / enrolment),
|
||||||
|
and when it was accepted.
|
||||||
|
- **Intake answers** — every registration-question answer, newest first:
|
||||||
|
question label, answer, and the registration it was given for.
|
||||||
|
- **Payment history** (`manage_billing` only) — every payment, newest first:
|
||||||
|
date, context, method, status, subtotal, HST, total, and receipt number.
|
||||||
|
|
||||||
Read-only in this iteration; cancel/edit actions are a possible follow-up.
|
Read-only in this iteration; cancel/edit actions are a possible follow-up (#70).
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
- `manage_students` — studio admin (administrators inherit it via the
|
- `manage_students` — studio admin (administrators inherit it via the
|
||||||
@@ -38,6 +48,11 @@ Read-only in this iteration; cancel/edit actions are a possible follow-up.
|
|||||||
`Availability\AvailabilityRepository::findById`,
|
`Availability\AvailabilityRepository::findById`,
|
||||||
`Offering\OfferingRepository::findById`,
|
`Offering\OfferingRepository::findById`,
|
||||||
`GroupClass\EnrollmentRepository::findByStudent` + `countActiveForStudent`
|
`GroupClass\EnrollmentRepository::findByStudent` + `countActiveForStudent`
|
||||||
|
- History sections: `Auth\StudentHistory` builds the display rows from
|
||||||
|
`Policy\AcceptanceRepository::findByStudent`,
|
||||||
|
`Registration\AnswerRepository::findByStudent`, and
|
||||||
|
`Payment\PaymentRepository::findByStudent`, resolving policy/version titles and
|
||||||
|
question labels (unit-tested with mocked repositories).
|
||||||
- Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested)
|
- Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested)
|
||||||
- The upcoming/past split is extracted into a small pure helper so it is
|
- The upcoming/past split is extracted into a small pure helper so it is
|
||||||
unit-testable (the controller itself follows the repo convention of not being
|
unit-testable (the controller itself follows the repo convention of not being
|
||||||
@@ -45,3 +60,7 @@ Read-only in this iteration; cancel/edit actions are a possible follow-up.
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
- `tests/Unit/Auth/StudentScheduleTest.php` (the pure upcoming/past split helper)
|
- `tests/Unit/Auth/StudentScheduleTest.php` (the pure upcoming/past split helper)
|
||||||
|
- `tests/Unit/Auth/StudentHistoryTest.php` (history display rows + fallbacks)
|
||||||
|
- `findByStudent` coverage in `tests/Unit/Policy/AcceptanceRepositoryTest.php`,
|
||||||
|
`tests/Unit/Registration/AnswerRepositoryTest.php`, and
|
||||||
|
`tests/Unit/Payment/PaymentRepositoryTest.php`
|
||||||
|
|||||||
+5
-2
@@ -13,6 +13,7 @@ use Unsupervised\Schedular\Auth\RegistrationController;
|
|||||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Auth\StudentController;
|
use Unsupervised\Schedular\Auth\StudentController;
|
||||||
|
use Unsupervised\Schedular\Auth\StudentHistory;
|
||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\Booking\LessonController;
|
use Unsupervised\Schedular\Booking\LessonController;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
@@ -25,10 +26,12 @@ use Unsupervised\Schedular\Payment\PaymentReportController;
|
|||||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||||
use Unsupervised\Schedular\Payment\PaymentService;
|
use Unsupervised\Schedular\Payment\PaymentService;
|
||||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||||
|
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||||
use Unsupervised\Schedular\Policy\PolicyController;
|
use Unsupervised\Schedular\Policy\PolicyController;
|
||||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||||
use Unsupervised\Schedular\Policy\PolicyService;
|
use Unsupervised\Schedular\Policy\PolicyService;
|
||||||
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
||||||
|
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||||
use Unsupervised\Schedular\Registration\QuestionController;
|
use Unsupervised\Schedular\Registration\QuestionController;
|
||||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@ class AdminMenu {
|
|||||||
private PaymentController $paymentController;
|
private PaymentController $paymentController;
|
||||||
private PaymentReportController $paymentReportController;
|
private PaymentReportController $paymentReportController;
|
||||||
|
|
||||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
|
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver ) {
|
||||||
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
||||||
$this->lessonController = new LessonController( $bookings, $payments, $availability );
|
$this->lessonController = new LessonController( $bookings, $payments, $availability );
|
||||||
$this->offeringController = new OfferingController( $offerings );
|
$this->offeringController = new OfferingController( $offerings );
|
||||||
@@ -58,7 +61,7 @@ class AdminMenu {
|
|||||||
$this->registrationController = new RegistrationController( $invites );
|
$this->registrationController = new RegistrationController( $invites );
|
||||||
$this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() );
|
$this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() );
|
||||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
||||||
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver );
|
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ) );
|
||||||
$this->instructorController = new InstructorController();
|
$this->instructorController = new InstructorController();
|
||||||
$this->settings = $settings;
|
$this->settings = $settings;
|
||||||
$this->accessSettings = new AccessSettings();
|
$this->accessSettings = new AccessSettings();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class StudentController {
|
|||||||
private OfferingRepository $offerings,
|
private OfferingRepository $offerings,
|
||||||
private EnrollmentRepository $enrollments,
|
private EnrollmentRepository $enrollments,
|
||||||
private BillingMethodResolver $resolver,
|
private BillingMethodResolver $resolver,
|
||||||
|
private StudentHistory $history,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function renderPage(): void {
|
public function renderPage(): void {
|
||||||
@@ -100,6 +101,10 @@ class StudentController {
|
|||||||
$this->enrollments->findByStudent( (int) $student->ID )
|
$this->enrollments->findByStudent( (int) $student->ID )
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$acceptances = $this->history->policyAcceptances( (int) $student->ID );
|
||||||
|
$intake = $this->history->intakeAnswers( (int) $student->ID );
|
||||||
|
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
|
||||||
|
|
||||||
$backUrl = admin_url( 'admin.php?page=us-students' );
|
$backUrl = admin_url( 'admin.php?page=us-students' );
|
||||||
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
|
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
|
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\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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every intake answer the student has submitted, newest registration first.
|
||||||
|
*
|
||||||
|
* @return list<array{question: string, answer: string, context: string}>
|
||||||
|
*/
|
||||||
|
public function intakeAnswers( int $studentId ): array {
|
||||||
|
return 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 ),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
$this->answers->findByStudent( $studentId )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ class Payment {
|
|||||||
public readonly ?string $receiptNumber = null,
|
public readonly ?string $receiptNumber = null,
|
||||||
public readonly ?string $receiptSentAt = null,
|
public readonly ?string $receiptSentAt = null,
|
||||||
public readonly ?string $paidAt = null,
|
public readonly ?string $paidAt = null,
|
||||||
|
public readonly ?string $createdAt = null,
|
||||||
public readonly ?int $id = null,
|
public readonly ?int $id = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ class Payment {
|
|||||||
receiptNumber: Val::stringOrNull( $row->receipt_number ),
|
receiptNumber: Val::stringOrNull( $row->receipt_number ),
|
||||||
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ),
|
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ),
|
||||||
paidAt: Val::stringOrNull( $row->paid_at ),
|
paidAt: Val::stringOrNull( $row->paid_at ),
|
||||||
|
createdAt: Val::stringOrNull( $row->created_at ),
|
||||||
id: Val::int( $row->id ),
|
id: Val::int( $row->id ),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -120,6 +122,7 @@ class Payment {
|
|||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'receipt_number' => $this->receiptNumber,
|
'receipt_number' => $this->receiptNumber,
|
||||||
'paid_at' => $this->paidAt,
|
'paid_at' => $this->paidAt,
|
||||||
|
'created_at' => $this->createdAt,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,23 @@ class PaymentRepository {
|
|||||||
return $row ? Payment::fromRow( $row ) : null;
|
return $row ? Payment::fromRow( $row ) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every payment for a student, newest first (admin payment history).
|
||||||
|
*
|
||||||
|
* @return list<Payment>
|
||||||
|
*/
|
||||||
|
public function findByStudent( int $studentId ): array {
|
||||||
|
$rows = $this->db->get_results(
|
||||||
|
$this->db->prepare(
|
||||||
|
'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC, id DESC',
|
||||||
|
$this->table,
|
||||||
|
$studentId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map( Payment::fromRow( ... ), $rows ?? [] );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pending payments, newest first (studio-admin confirmation queue).
|
* Pending payments, newest first (studio-admin confirmation queue).
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@ class Plugin {
|
|||||||
( new RoleManager() )->register();
|
( new RoleManager() )->register();
|
||||||
( new RegistrationLoginGate() )->register();
|
( new RegistrationLoginGate() )->register();
|
||||||
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
||||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
|
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
|
||||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register();
|
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $paymentService ) )->register();
|
||||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
||||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
||||||
|
|||||||
@@ -55,4 +55,21 @@ class AcceptanceRepository {
|
|||||||
|
|
||||||
return array_map( PolicyAcceptance::fromRow( ... ), $rows ?? [] );
|
return array_map( PolicyAcceptance::fromRow( ... ), $rows ?? [] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find every acceptance a student has recorded, newest first.
|
||||||
|
*
|
||||||
|
* @return list<PolicyAcceptance>
|
||||||
|
*/
|
||||||
|
public function findByStudent( int $studentId ): array {
|
||||||
|
$rows = $this->db->get_results(
|
||||||
|
$this->db->prepare(
|
||||||
|
'SELECT * FROM %i WHERE student_id = %d ORDER BY accepted_at DESC, id DESC',
|
||||||
|
$this->table,
|
||||||
|
$studentId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map( PolicyAcceptance::fromRow( ... ), $rows ?? [] );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,4 +55,21 @@ class AnswerRepository {
|
|||||||
|
|
||||||
return array_map( Answer::fromRow( ... ), $rows ?? [] );
|
return array_map( Answer::fromRow( ... ), $rows ?? [] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find every answer a student has submitted, newest registration first.
|
||||||
|
*
|
||||||
|
* @return list<Answer>
|
||||||
|
*/
|
||||||
|
public function findByStudent( int $studentId ): array {
|
||||||
|
$rows = $this->db->get_results(
|
||||||
|
$this->db->prepare(
|
||||||
|
'SELECT * FROM %i WHERE student_id = %d ORDER BY id DESC',
|
||||||
|
$this->table,
|
||||||
|
$studentId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_map( Answer::fromRow( ... ), $rows ?? [] );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming
|
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $upcoming
|
||||||
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
|
* @var list<array{start_dt: string, end_dt: string, offering: string, instructor: string, status: string}> $past
|
||||||
* @var list<array{offering: string, status: string}> $enrolments
|
* @var list<array{offering: string, status: string}> $enrolments
|
||||||
|
* @var list<array{policy: string, version: string, context: string, accepted_at: string}> $acceptances
|
||||||
|
* @var list<array{question: string, answer: string, context: string}> $intake
|
||||||
|
* @var list<array{created_at: string, context: string, method: string, status: string, amount: float, tax_amount: float, total: float, currency: string, receipt: string}> $payments
|
||||||
* @var string $backUrl
|
* @var string $backUrl
|
||||||
* @var bool $canBilling
|
* @var bool $canBilling
|
||||||
* @var string $billingOverride
|
* @var string $billingOverride
|
||||||
@@ -104,4 +107,90 @@ $renderLessons = static function (array $rows): void {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e('Policy acceptances', 'unsupervised-schedular'); ?></h2>
|
||||||
|
<?php if (empty($acceptances)) : ?>
|
||||||
|
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="wp-list-table widefat fixed striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Policy', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Version', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Context', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Accepted', 'unsupervised-schedular'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($acceptances as $acceptance) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo esc_html($acceptance['policy']); ?></td>
|
||||||
|
<td><?php echo esc_html($acceptance['version']); ?></td>
|
||||||
|
<td><?php echo esc_html($acceptance['context']); ?></td>
|
||||||
|
<td><?php echo esc_html($acceptance['accepted_at'] !== '' ? (string) mysql2date('M j, Y g:i A', $acceptance['accepted_at']) : '—'); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e('Intake answers', 'unsupervised-schedular'); ?></h2>
|
||||||
|
<?php if (empty($intake)) : ?>
|
||||||
|
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="wp-list-table widefat fixed striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Question', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Answer', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Context', 'unsupervised-schedular'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($intake as $row) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo esc_html($row['question']); ?></td>
|
||||||
|
<td><?php echo esc_html($row['answer']); ?></td>
|
||||||
|
<td><?php echo esc_html($row['context']); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($canBilling) : ?>
|
||||||
|
<h2><?php esc_html_e('Payment history', 'unsupervised-schedular'); ?></h2>
|
||||||
|
<?php if (empty($payments)) : ?>
|
||||||
|
<p><?php esc_html_e('None.', 'unsupervised-schedular'); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<table class="wp-list-table widefat fixed striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e('Date', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Context', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Method', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Status', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Subtotal', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('HST', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Total', 'unsupervised-schedular'); ?></th>
|
||||||
|
<th><?php esc_html_e('Receipt', 'unsupervised-schedular'); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($payments as $payment) : ?>
|
||||||
|
<tr>
|
||||||
|
<td><?php echo esc_html($payment['created_at'] !== '' ? (string) mysql2date('M j, Y g:i A', $payment['created_at']) : '—'); ?></td>
|
||||||
|
<td><?php echo esc_html($payment['context']); ?></td>
|
||||||
|
<td><?php echo esc_html($payment['method']); ?></td>
|
||||||
|
<td><?php echo esc_html($payment['status']); ?></td>
|
||||||
|
<td><?php echo esc_html(number_format_i18n($payment['amount'], 2)); ?></td>
|
||||||
|
<td><?php echo esc_html(number_format_i18n($payment['tax_amount'], 2)); ?></td>
|
||||||
|
<td><?php echo esc_html(number_format_i18n($payment['total'], 2) . ' ' . $payment['currency']); ?></td>
|
||||||
|
<td><?php echo esc_html($payment['receipt']); ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
||||||
|
|
||||||
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Auth\StudentHistory;
|
||||||
|
use Unsupervised\Schedular\Payment\Payment;
|
||||||
|
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||||
|
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||||
|
use Unsupervised\Schedular\Policy\Policy;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||||
|
use Unsupervised\Schedular\Policy\PolicyVersion;
|
||||||
|
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;
|
||||||
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
class StudentHistoryTest extends TestCase
|
||||||
|
{
|
||||||
|
private AcceptanceRepository&Mockery\MockInterface $acceptances;
|
||||||
|
private PolicyRepository&Mockery\MockInterface $policies;
|
||||||
|
private PolicyVersionRepository&Mockery\MockInterface $policyVersions;
|
||||||
|
private AnswerRepository&Mockery\MockInterface $answers;
|
||||||
|
private QuestionRepository&Mockery\MockInterface $questions;
|
||||||
|
private PaymentRepository&Mockery\MockInterface $payments;
|
||||||
|
private StudentHistory $history;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->acceptances = Mockery::mock(AcceptanceRepository::class);
|
||||||
|
$this->policies = Mockery::mock(PolicyRepository::class);
|
||||||
|
$this->policyVersions = Mockery::mock(PolicyVersionRepository::class);
|
||||||
|
$this->answers = Mockery::mock(AnswerRepository::class);
|
||||||
|
$this->questions = Mockery::mock(QuestionRepository::class);
|
||||||
|
$this->payments = Mockery::mock(PaymentRepository::class);
|
||||||
|
|
||||||
|
$this->history = new StudentHistory(
|
||||||
|
$this->acceptances,
|
||||||
|
$this->policies,
|
||||||
|
$this->policyVersions,
|
||||||
|
$this->answers,
|
||||||
|
$this->questions,
|
||||||
|
$this->payments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPolicyAcceptancesResolvePolicyTitleAndVersion(): void
|
||||||
|
{
|
||||||
|
$this->acceptances->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||||
|
new PolicyAcceptance(9, 5, PolicyAcceptance::REG_ACCOUNT, 5, null, '2026-06-02 09:00:00', 1),
|
||||||
|
]);
|
||||||
|
$this->policyVersions->shouldReceive('findById')->with(9)
|
||||||
|
->andReturn(new PolicyVersion(2, 3, null, PolicyVersion::STATUS_PUBLISHED, id: 9));
|
||||||
|
$this->policies->shouldReceive('findById')->with(2)
|
||||||
|
->andReturn(new Policy('Waiver', 'waiver', 9, Policy::SCOPE_SIGNUP, 2));
|
||||||
|
|
||||||
|
$rows = $this->history->policyAcceptances(5);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'policy' => 'Waiver',
|
||||||
|
'version' => 'v3',
|
||||||
|
'context' => 'Account signup',
|
||||||
|
'accepted_at' => '2026-06-02 09:00:00',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPolicyAcceptancesFallBackWhenVersionIsGone(): void
|
||||||
|
{
|
||||||
|
$this->acceptances->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||||
|
new PolicyAcceptance(9, 5, PolicyAcceptance::REG_LESSON, 12),
|
||||||
|
]);
|
||||||
|
$this->policyVersions->shouldReceive('findById')->with(9)->andReturn(null);
|
||||||
|
|
||||||
|
$rows = $this->history->policyAcceptances(5);
|
||||||
|
|
||||||
|
self::assertSame('#9', $rows[0]['policy']);
|
||||||
|
self::assertSame('—', $rows[0]['version']);
|
||||||
|
self::assertSame('Lesson #12', $rows[0]['context']);
|
||||||
|
self::assertSame('', $rows[0]['accepted_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIntakeAnswersResolveQuestionLabels(): void
|
||||||
|
{
|
||||||
|
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||||
|
new Answer(4, Answer::REG_ENROLLMENT, 3, 5, 'Beginner', 1),
|
||||||
|
]);
|
||||||
|
$this->questions->shouldReceive('findById')->with(4)
|
||||||
|
->andReturn(new Question(1, 'Experience level', id: 4));
|
||||||
|
|
||||||
|
$rows = $this->history->intakeAnswers(5);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'question' => 'Experience level',
|
||||||
|
'answer' => 'Beginner',
|
||||||
|
'context' => 'Enrolment #3',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIntakeAnswersFallBackWhenQuestionIsGone(): void
|
||||||
|
{
|
||||||
|
$this->answers->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||||
|
new Answer(4, Answer::REG_LESSON, 12, 5, null, 1),
|
||||||
|
]);
|
||||||
|
$this->questions->shouldReceive('findById')->with(4)->andReturn(null);
|
||||||
|
|
||||||
|
$rows = $this->history->intakeAnswers(5);
|
||||||
|
|
||||||
|
self::assertSame('#4', $rows[0]['question']);
|
||||||
|
self::assertSame('—', $rows[0]['answer']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPaymentsBuildDisplayRows(): void
|
||||||
|
{
|
||||||
|
$this->payments->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||||
|
new Payment(
|
||||||
|
5,
|
||||||
|
3,
|
||||||
|
Payment::REG_LESSON,
|
||||||
|
12,
|
||||||
|
100.00,
|
||||||
|
'CAD',
|
||||||
|
Payment::METHOD_CARD,
|
||||||
|
Payment::STATUS_PAID,
|
||||||
|
taxRate: 13.0,
|
||||||
|
taxAmount: 13.00,
|
||||||
|
receiptNumber: 'USC-7',
|
||||||
|
createdAt: '2026-06-08 09:00:00',
|
||||||
|
id: 50
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = $this->history->payments(5);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'created_at' => '2026-06-08 09:00:00',
|
||||||
|
'context' => 'Lesson #12',
|
||||||
|
'method' => Payment::METHOD_CARD,
|
||||||
|
'status' => Payment::STATUS_PAID,
|
||||||
|
'amount' => 100.00,
|
||||||
|
'tax_amount' => 13.00,
|
||||||
|
'total' => 113.00,
|
||||||
|
'currency' => 'CAD',
|
||||||
|
'receipt' => 'USC-7',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPaymentsFallBackWhenUnpaidAndUndated(): void
|
||||||
|
{
|
||||||
|
$this->payments->shouldReceive('findByStudent')->once()->with(5)->andReturn([
|
||||||
|
new Payment(5, 3, Payment::REG_ENROLLMENT, 3, 40.00, id: 51),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = $this->history->payments(5);
|
||||||
|
|
||||||
|
self::assertSame('', $rows[0]['created_at']);
|
||||||
|
self::assertSame('Enrolment #3', $rows[0]['context']);
|
||||||
|
self::assertSame('—', $rows[0]['receipt']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -158,6 +158,22 @@ class PaymentRepositoryTest extends TestCase
|
|||||||
self::assertCount(1, $this->repo->findPending());
|
self::assertCount(1, $this->repo->findPending());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testFindByStudentPreparesQueryAndMaps(): void
|
||||||
|
{
|
||||||
|
$this->db->shouldReceive('prepare')
|
||||||
|
->once()
|
||||||
|
->with(Mockery::pattern('/student_id = %d ORDER BY created_at DESC, id DESC/'), 'wp_us_payments', 5)
|
||||||
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
|
$this->db->shouldReceive('get_results')->andReturn([$this->row()]);
|
||||||
|
|
||||||
|
$payments = $this->repo->findByStudent(5);
|
||||||
|
|
||||||
|
self::assertCount(1, $payments);
|
||||||
|
self::assertSame(5, $payments[0]->studentId);
|
||||||
|
self::assertSame('2026-06-08 09:00:00', $payments[0]->createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
private function row(): object
|
private function row(): object
|
||||||
{
|
{
|
||||||
return (object) [
|
return (object) [
|
||||||
@@ -177,6 +193,7 @@ class PaymentRepositoryTest extends TestCase
|
|||||||
'receipt_number' => null,
|
'receipt_number' => null,
|
||||||
'receipt_sent_at' => null,
|
'receipt_sent_at' => null,
|
||||||
'paid_at' => null,
|
'paid_at' => null,
|
||||||
|
'created_at' => '2026-06-08 09:00:00',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class PaymentTest extends TestCase
|
|||||||
'receipt_number' => 'USC-7',
|
'receipt_number' => 'USC-7',
|
||||||
'receipt_sent_at' => null,
|
'receipt_sent_at' => null,
|
||||||
'paid_at' => '2026-06-08 10:00:00',
|
'paid_at' => '2026-06-08 10:00:00',
|
||||||
|
'created_at' => '2026-06-08 09:00:00',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertSame(7, $payment->id);
|
self::assertSame(7, $payment->id);
|
||||||
@@ -55,6 +56,7 @@ class PaymentTest extends TestCase
|
|||||||
self::assertSame(Payment::METHOD_COMP, $payment->method);
|
self::assertSame(Payment::METHOD_COMP, $payment->method);
|
||||||
self::assertTrue($payment->isPaid());
|
self::assertTrue($payment->isPaid());
|
||||||
self::assertSame('USC-7', $payment->receiptNumber);
|
self::assertSame('USC-7', $payment->receiptNumber);
|
||||||
|
self::assertSame('2026-06-08 09:00:00', $payment->createdAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testTotalAddsTaxToAmount(): void
|
public function testTotalAddsTaxToAmount(): void
|
||||||
|
|||||||
@@ -90,4 +90,29 @@ class AcceptanceRepositoryTest extends TestCase
|
|||||||
self::assertCount(1, $rows);
|
self::assertCount(1, $rows);
|
||||||
self::assertInstanceOf(PolicyAcceptance::class, $rows[0]);
|
self::assertInstanceOf(PolicyAcceptance::class, $rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testFindByStudentMapsRowsNewestFirst(): void
|
||||||
|
{
|
||||||
|
$this->db->shouldReceive('prepare')
|
||||||
|
->once()
|
||||||
|
->with(Mockery::pattern('/student_id = %d ORDER BY accepted_at DESC, id DESC/'), 'wp_us_policy_acceptances', 5)
|
||||||
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
|
$this->db->shouldReceive('get_results')->andReturn([
|
||||||
|
(object) [
|
||||||
|
'id' => '2',
|
||||||
|
'policy_version_id' => '9',
|
||||||
|
'student_id' => '5',
|
||||||
|
'registration_type' => PolicyAcceptance::REG_ACCOUNT,
|
||||||
|
'registration_id' => '5',
|
||||||
|
'accepted_at' => '2026-06-03 09:00:00',
|
||||||
|
'ip_address' => null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rows = $this->repo->findByStudent(5);
|
||||||
|
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
self::assertSame(5, $rows[0]->studentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,4 +98,32 @@ class AnswerRepositoryTest extends TestCase
|
|||||||
self::assertInstanceOf(Answer::class, $answers[0]);
|
self::assertInstanceOf(Answer::class, $answers[0]);
|
||||||
self::assertSame(3, $answers[0]->questionId);
|
self::assertSame(3, $answers[0]->questionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testFindByStudentPreparesQueryAndMaps(): void
|
||||||
|
{
|
||||||
|
$row = (object) [
|
||||||
|
'id' => '1',
|
||||||
|
'question_id' => '3',
|
||||||
|
'registration_type' => Answer::REG_LESSON,
|
||||||
|
'registration_id' => '12',
|
||||||
|
'student_id' => '5',
|
||||||
|
'answer_value' => 'Beginner',
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->db->shouldReceive('prepare')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
Mockery::pattern('/student_id = %d ORDER BY id DESC/'),
|
||||||
|
'wp_us_question_answers',
|
||||||
|
5
|
||||||
|
)
|
||||||
|
->andReturn('SELECT ...');
|
||||||
|
|
||||||
|
$this->db->shouldReceive('get_results')->andReturn([$row]);
|
||||||
|
|
||||||
|
$answers = $this->repo->findByStudent(5);
|
||||||
|
|
||||||
|
self::assertCount(1, $answers);
|
||||||
|
self::assertSame(5, $answers[0]->studentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user