Credit students for cancelled paid lessons
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
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]>
This commit is contained in:
@@ -44,16 +44,16 @@ class ScheduledBillingRunner {
|
||||
|
||||
// One notice bucket per student, filled as pending payments are created and
|
||||
// flushed to a single email at the end, so a student billed for several
|
||||
// lessons on one day is emailed once — never once per lesson. $batchIds
|
||||
// tracks the payment ids behind each student's bucket so they can be tagged
|
||||
// with a shared reference for lump-sum e-transfer reconciliation.
|
||||
$buckets = [];
|
||||
$batchIds = [];
|
||||
// lessons on one day is emailed once — never once per lesson. Each entry keeps
|
||||
// the created payment and its label; credits are applied across the whole
|
||||
// bucket before the notice is built, so a student's account credit offsets the
|
||||
// run's charges oldest-first.
|
||||
$buckets = [];
|
||||
|
||||
$this->billPrivateLessons( $now, $buckets, $batchIds );
|
||||
$this->billGroupEnrollments( $now, $buckets, $batchIds );
|
||||
$this->billPrivateLessons( $now, $buckets );
|
||||
$this->billGroupEnrollments( $now, $buckets );
|
||||
|
||||
$this->sendNotices( $buckets, $batchIds );
|
||||
$this->sendNotices( $buckets );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,10 +61,9 @@ class ScheduledBillingRunner {
|
||||
* are within 24 hours; monthly lessons are grouped per calendar month and billed
|
||||
* one payment for the month once its 1st has arrived.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void {
|
||||
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets ): void {
|
||||
$today = $now->format( 'Y-m-d' );
|
||||
$monthly = [];
|
||||
|
||||
@@ -109,7 +108,6 @@ class ScheduledBillingRunner {
|
||||
|
||||
$this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_LESSON,
|
||||
$lessonId,
|
||||
$studentId,
|
||||
@@ -123,7 +121,7 @@ class ScheduledBillingRunner {
|
||||
);
|
||||
}
|
||||
|
||||
$this->billMonthlyLessonGroups( $today, $monthly, $buckets, $batchIds );
|
||||
$this->billMonthlyLessonGroups( $today, $monthly, $buckets );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,10 +130,9 @@ class ScheduledBillingRunner {
|
||||
* lesson in the group; the rest are pointed at it so they are not re-billed.
|
||||
*
|
||||
* @param array<string, list<array{lesson_id: int, student_id: int, instructor_id: int, currency: string, etransfer: ?string, title: string, price: float, start: \DateTimeImmutable}>> $monthly
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets, array &$batchIds ): void {
|
||||
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets ): void {
|
||||
foreach ( $monthly as $group ) {
|
||||
$first = $group[0]['start'];
|
||||
$monthStart = $first->format( 'Y-m-01' );
|
||||
@@ -151,7 +148,6 @@ class ScheduledBillingRunner {
|
||||
|
||||
$payment = $this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_LESSON,
|
||||
$anchorId,
|
||||
$group[0]['student_id'],
|
||||
@@ -188,10 +184,9 @@ class ScheduledBillingRunner {
|
||||
* per month (on the 1st) for that month's sessions. Dedup is by `period_key`
|
||||
* since a single enrolment maps to many periodic charges.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void {
|
||||
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets ): void {
|
||||
$today = $now->format( 'Y-m-d' );
|
||||
$offerings = [];
|
||||
|
||||
@@ -211,9 +206,9 @@ class ScheduledBillingRunner {
|
||||
}
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
|
||||
$this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets, $batchIds );
|
||||
$this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets );
|
||||
} else {
|
||||
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets, $batchIds );
|
||||
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,10 +217,9 @@ class ScheduledBillingRunner {
|
||||
* Bill one payment per group-class session that is now within 24 hours.
|
||||
*
|
||||
* @param list<array{start: string, end: string}> $windows
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void {
|
||||
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void {
|
||||
foreach ( $windows as $window ) {
|
||||
$start = new \DateTimeImmutable( $window['start'] );
|
||||
$due = $start->modify( '-1 day' );
|
||||
@@ -240,7 +234,6 @@ class ScheduledBillingRunner {
|
||||
|
||||
$this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_ENROLLMENT,
|
||||
(int) $enrollment->id,
|
||||
$enrollment->studentId,
|
||||
@@ -259,10 +252,9 @@ class ScheduledBillingRunner {
|
||||
* Bill one payment per calendar month of a group class, once its 1st arrives.
|
||||
*
|
||||
* @param list<array{start: string, end: string}> $windows
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||
// Count this enrolment's sessions per calendar month.
|
||||
$months = [];
|
||||
foreach ( $windows as $window ) {
|
||||
@@ -282,7 +274,6 @@ class ScheduledBillingRunner {
|
||||
|
||||
$this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_ENROLLMENT,
|
||||
(int) $enrollment->id,
|
||||
$enrollment->studentId,
|
||||
@@ -305,46 +296,72 @@ class ScheduledBillingRunner {
|
||||
|
||||
/**
|
||||
* Create one scheduled payment and, when it is pending (not a comp auto-pay),
|
||||
* add an itemised line to the student's notice bucket and record its payment id
|
||||
* for the shared notice batch. Returns the created payment, or null when there
|
||||
* was nothing to charge.
|
||||
* add it to the student's notice bucket with the label to show on the notice.
|
||||
* Credits are applied later, once the whole bucket is known. Returns the created
|
||||
* payment, or null when there was nothing to charge.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function bill( array &$buckets, array &$batchIds, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment {
|
||||
private function bill( array &$buckets, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment {
|
||||
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey );
|
||||
|
||||
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$buckets[ $studentId ][] = [
|
||||
'label' => $label,
|
||||
'amount' => $payment->total(),
|
||||
'currency' => $payment->currency,
|
||||
'due_date' => $payment->dueDate,
|
||||
'etransfer_email' => $payment->etransferEmail,
|
||||
$buckets[ $studentId ][] = [
|
||||
'payment' => $payment,
|
||||
'label' => $label,
|
||||
];
|
||||
$batchIds[ $studentId ][] = $payment->id;
|
||||
}
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag each student's payments with a shared batch reference and email them one
|
||||
* itemised notice quoting it, so a lump-sum e-transfer can be reconciled to the
|
||||
* exact pending payments it covers.
|
||||
* For each student, apply any account credit they hold against the run's charges,
|
||||
* tag the payments they still owe with a shared batch reference, and email them
|
||||
* one itemised notice. The notice lists each charge at its full amount, then the
|
||||
* credit applied and the reduced total due; a charge fully covered by credit is
|
||||
* already settled and carries no reference. A lump-sum e-transfer for the balance
|
||||
* reconciles to the reference.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function sendNotices( array $buckets, array $batchIds ): void {
|
||||
foreach ( $buckets as $studentId => $items ) {
|
||||
$reference = $this->reference();
|
||||
$this->payments->assignNoticeBatch( $batchIds[ $studentId ] ?? [], $reference );
|
||||
private function sendNotices( array $buckets ): void {
|
||||
foreach ( $buckets as $studentId => $entries ) {
|
||||
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
|
||||
$applied = $this->payments->applyCredits( $studentId, $payments );
|
||||
|
||||
$items = [];
|
||||
$batchIds = [];
|
||||
$creditTotal = 0.0;
|
||||
|
||||
foreach ( $entries as $entry ) {
|
||||
$payment = $entry['payment'];
|
||||
$id = (int) $payment->id;
|
||||
$credited = $applied[ $id ] ?? 0.0;
|
||||
|
||||
$creditTotal += $credited;
|
||||
|
||||
$items[] = [
|
||||
'label' => $entry['label'],
|
||||
'amount' => $payment->total(),
|
||||
'currency' => $payment->currency,
|
||||
'due_date' => $payment->dueDate,
|
||||
'etransfer_email' => $payment->etransferEmail,
|
||||
];
|
||||
|
||||
// A charge still carrying a balance is what a lump-sum e-transfer covers;
|
||||
// one fully settled by credit needs no reconciliation reference.
|
||||
if ( round( $payment->total() - $credited, 2 ) > 0.0 ) {
|
||||
$batchIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
$reference = [] !== $batchIds ? $this->reference() : '';
|
||||
$this->payments->assignNoticeBatch( $batchIds, $reference );
|
||||
|
||||
$user = get_userdata( $studentId );
|
||||
if ( $user instanceof \WP_User ) {
|
||||
$this->mailer->send( $user, $items, $reference );
|
||||
$this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user