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:
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
class CreditRepository {
|
||||
|
||||
private string $table;
|
||||
|
||||
public function __construct( private \wpdb $db ) {
|
||||
$this->table = $db->prefix . 'us_credits';
|
||||
}
|
||||
|
||||
public function insert( Credit $credit ): int {
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'student_id' => $credit->studentId,
|
||||
'amount' => $credit->amount,
|
||||
'remaining' => $credit->remaining,
|
||||
'currency' => $credit->currency,
|
||||
'source_payment_id' => $credit->sourcePaymentId,
|
||||
'source_lesson_id' => $credit->sourceLessonId,
|
||||
'reason' => $credit->reason,
|
||||
'status' => $credit->status,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
public function findById( int $id ): ?Credit {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id )
|
||||
);
|
||||
|
||||
return $row ? Credit::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a credit has already been issued for a cancelled lesson, so cancelling
|
||||
* (or re-cancelling) the same lesson never grants a second credit.
|
||||
*/
|
||||
public function existsForLesson( int $lessonId ): bool {
|
||||
$found = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT id FROM %i WHERE source_lesson_id = %d LIMIT 1',
|
||||
$this->table,
|
||||
$lessonId
|
||||
)
|
||||
);
|
||||
|
||||
return null !== $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* A student's total unused credit balance (sum of the remaining amounts of every
|
||||
* still-available credit).
|
||||
*/
|
||||
public function availableBalance( int $studentId ): float {
|
||||
$total = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE student_id = %d AND status = %s',
|
||||
$this->table,
|
||||
$studentId,
|
||||
Credit::STATUS_AVAILABLE
|
||||
)
|
||||
);
|
||||
|
||||
return round( (float) $total, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* A student's still-available credits, oldest first — the FIFO order they are
|
||||
* consumed in.
|
||||
*
|
||||
* @return list<Credit>
|
||||
*/
|
||||
public function findAvailableByStudent( int $studentId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE student_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC',
|
||||
$this->table,
|
||||
$studentId,
|
||||
Credit::STATUS_AVAILABLE
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Credit::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Every credit for a student, newest first (admin history).
|
||||
*
|
||||
* @return list<Credit>
|
||||
*/
|
||||
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( Credit::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw down a student's credit balance by $amount, consuming their available
|
||||
* credits oldest first and marking each fully-spent credit `consumed`. Stops once
|
||||
* the amount is exhausted; a balance shorter than $amount simply drains to zero.
|
||||
*/
|
||||
public function consume( int $studentId, float $amount ): void {
|
||||
$remaining = round( $amount, 2 );
|
||||
if ( $remaining <= 0.0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $this->findAvailableByStudent( $studentId ) as $credit ) {
|
||||
if ( $remaining <= 0.0 ) {
|
||||
break;
|
||||
}
|
||||
if ( null === $credit->id ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$take = min( $credit->remaining, $remaining );
|
||||
$newRemaining = round( $credit->remaining - $take, 2 );
|
||||
$status = $newRemaining <= 0.0 ? Credit::STATUS_CONSUMED : Credit::STATUS_AVAILABLE;
|
||||
|
||||
$this->db->update(
|
||||
$this->table,
|
||||
[
|
||||
'remaining' => $newRemaining,
|
||||
'status' => $status,
|
||||
'updated_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ 'id' => $credit->id ],
|
||||
[ '%f', '%s', '%s' ],
|
||||
[ '%d' ]
|
||||
);
|
||||
|
||||
$remaining = round( $remaining - $take, 2 );
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user