Files
unsupervised-scheduler/src/GroupClass/EnrollmentRepository.php
T
thatguygriffandClaude Opus 4.8 4328e8fb5f
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
Add weekly and monthly scheduled billing for offerings
Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:06:37 -03:00

192 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
class EnrollmentRepository {
private string $table;
public function __construct( private \wpdb $db ) {
$this->table = $db->prefix . 'us_group_enrollments';
}
public function insert( Enrollment $enrollment ): int {
$this->db->insert(
$this->table,
[
'offering_id' => $enrollment->offeringId,
'student_id' => $enrollment->studentId,
'instructor_id' => $enrollment->instructorId,
'status' => $enrollment->status,
'payment_id' => $enrollment->paymentId,
'enrolled_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
}
public function findById( int $id ): ?Enrollment {
$row = $this->db->get_row(
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
);
return $row ? Enrollment::fromRow( $row ) : null;
}
/**
* Count active enrolments for an offering (capacity check).
*/
public function countActiveForOffering( int $offeringId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND status = %s',
$this->table,
$offeringId,
Enrollment::STATUS_ACTIVE
)
);
}
/**
* Count a student's active group-class enrolments.
*/
public function countActiveForStudent( int $studentId ): int {
return (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE student_id = %d AND status = %s',
$this->table,
$studentId,
Enrollment::STATUS_ACTIVE
)
);
}
/**
* Whether a student already holds an active enrolment in an offering.
*/
public function hasActiveEnrollment( int $offeringId, int $studentId ): bool {
$count = (int) $this->db->get_var(
$this->db->prepare(
'SELECT COUNT(*) FROM %i WHERE offering_id = %d AND student_id = %d AND status = %s',
$this->table,
$offeringId,
$studentId,
Enrollment::STATUS_ACTIVE
)
);
return $count > 0;
}
/**
* A student's enrolments, newest first.
*
* @return list<Enrollment>
*/
public function findByStudent( int $studentId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE student_id = %d ORDER BY enrolled_at DESC',
$this->table,
$studentId
)
);
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
/**
* Enrolments in an instructor's group classes, newest first.
*
* @return list<Enrollment>
*/
public function findByInstructor( int $instructorId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE instructor_id = %d ORDER BY enrolled_at DESC',
$this->table,
$instructorId
)
);
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
/**
* All active enrolments across instructors (studio-admin view).
*
* @return list<Enrollment>
*/
public function findAllActive(): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE status = %s ORDER BY enrolled_at DESC',
$this->table,
Enrollment::STATUS_ACTIVE
)
);
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
/**
* Active enrolments whose group class bills on a scheduled mode (weekly /
* monthly) — the source rows for the daily billing scan. Filtered by joining
* the offering so only classes actually on a scheduled plan are returned.
*
* @param list<string> $modes Billing modes to include (e.g. weekly, monthly).
* @return list<Enrollment>
*/
public function findActiveByBillingModes( array $modes ): array {
if ( [] === $modes ) {
return [];
}
$offTable = str_replace( 'us_group_enrollments', 'us_offerings', $this->table );
$placeholders = implode( ', ', array_fill( 0, count( $modes ), '%s' ) );
$rows = $this->db->get_results(
$this->db->prepare(
"SELECT e.* FROM %i e
JOIN %i o ON o.id = e.offering_id
WHERE e.status = %s
AND o.billing_mode IN ( {$placeholders} )
ORDER BY e.student_id ASC, e.offering_id ASC",
$this->table,
$offTable,
Enrollment::STATUS_ACTIVE,
...$modes
)
);
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
public function setPaymentId( int $id, int $paymentId ): bool {
return false !== $this->db->update(
$this->table,
[ 'payment_id' => $paymentId ],
[ 'id' => $id ],
[ '%d' ],
[ '%d' ]
);
}
public function updateStatus( int $id, string $status ): bool {
if ( ! in_array( $status, Enrollment::VALID_STATUSES, true ) ) {
return false;
}
return (bool) $this->db->update(
$this->table,
[ 'status' => $status ],
[ 'id' => $id ],
[ '%s' ],
[ '%d' ]
);
}
}