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:
@@ -21,6 +21,7 @@ class PaymentService {
|
||||
private EnrollmentRepository $enrollments,
|
||||
private StudioSettings $settings,
|
||||
private StripeGateway $stripe,
|
||||
private CreditRepository $credits,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -134,6 +135,135 @@ class PaymentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit a student for a cancelled lesson they had already paid for. The credit
|
||||
* is one lesson's share of the covering payment's total (including tax) — the
|
||||
* whole total for a single-lesson payment, or `total ÷ lessons covered` for a
|
||||
* payment that spans several (a monthly scheduled charge, or a weekly series paid
|
||||
* upfront). The original payment is left untouched; the credit is applied to the
|
||||
* student's future scheduled-billing charges. Returns null when the lesson was
|
||||
* never paid, has no covering payment, or was already credited.
|
||||
*/
|
||||
public function creditForCancelledLesson( Lesson $lesson ): ?Credit {
|
||||
if ( null === $lesson->id ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$paymentId = $lesson->paymentId;
|
||||
if ( null === $paymentId && null !== $lesson->seriesId ) {
|
||||
// Series lessons other than the anchor carry no payment_id of their own;
|
||||
// the whole reservation is paid through the anchor's payment.
|
||||
$anchor = $this->payments->findByRegistration( Payment::REG_LESSON, $lesson->seriesId );
|
||||
$paymentId = $anchor?->id;
|
||||
}
|
||||
if ( null === $paymentId ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payment = $this->payments->findById( $paymentId );
|
||||
if ( null === $payment || ! $payment->isPaid() ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( $this->credits->existsForLesson( $lesson->id ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$share = round( $payment->total() / $this->coveredLessonCount( $lesson, $payment ), 2 );
|
||||
if ( $share <= 0.0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = $this->credits->insert(
|
||||
new Credit(
|
||||
studentId: $payment->studentId,
|
||||
amount: $share,
|
||||
remaining: $share,
|
||||
currency: $payment->currency,
|
||||
sourcePaymentId: $payment->id,
|
||||
sourceLessonId: $lesson->id,
|
||||
reason: sprintf(
|
||||
/* translators: %d: cancelled lesson id */
|
||||
__( 'Credit for cancelled lesson #%d', 'unsupervised-schedular' ),
|
||||
$lesson->id
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
return $this->credits->findById( $id );
|
||||
}
|
||||
|
||||
/**
|
||||
* How many lessons the covering payment was billed for, so its total can be split
|
||||
* into a per-lesson credit. A weekly series paid upfront (unscheduled) covers the
|
||||
* whole series; every other case — a single booking, a weekly scheduled lesson
|
||||
* (one payment each), or a monthly scheduled charge (payment linked to each
|
||||
* lesson) — is answered by how many lessons point at the payment. Never below one.
|
||||
*/
|
||||
private function coveredLessonCount( Lesson $lesson, Payment $payment ): int {
|
||||
if ( ! $payment->isScheduled() && null !== $lesson->seriesId ) {
|
||||
return max( 1, $this->bookings->countBySeries( $lesson->seriesId ) );
|
||||
}
|
||||
|
||||
return max( 1, $this->bookings->countByPaymentId( (int) $payment->id ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a student's available credit balance against a set of freshly-created
|
||||
* pending payments (the ones a billing scan just generated for them), oldest
|
||||
* charge first. Each payment's `credit_applied` is raised by the amount covered;
|
||||
* a payment fully covered is marked paid-by-credit and its registration confirmed
|
||||
* so it leaves the confirmation queue. The credit ledger is drawn down by the
|
||||
* total applied. Returns a map of payment id to the credit applied to it, so the
|
||||
* caller can reflect the reduction on the student's notice.
|
||||
*
|
||||
* @param list<Payment> $payments
|
||||
* @return array<int, float>
|
||||
*/
|
||||
public function applyCredits( int $studentId, array $payments ): array {
|
||||
$balance = $this->credits->availableBalance( $studentId );
|
||||
if ( $balance <= 0.0 ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$applied = [];
|
||||
$consumed = 0.0;
|
||||
|
||||
foreach ( $payments as $payment ) {
|
||||
if ( null === $payment->id || $balance <= 0.0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$owing = $payment->netDue();
|
||||
if ( $owing <= 0.0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = round( min( $balance, $owing ), 2 );
|
||||
if ( $amount <= 0.0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->payments->addCreditApplied( $payment->id, $amount );
|
||||
|
||||
// Fully covered by credit: settle it so it drops out of the pending queue.
|
||||
if ( $amount >= $owing ) {
|
||||
$this->payments->markPaid( $payment->id, 'USC-' . $payment->id );
|
||||
$this->confirmRegistration( $payment->registrationType, $payment->registrationId );
|
||||
}
|
||||
|
||||
$applied[ $payment->id ] = $amount;
|
||||
$balance = round( $balance - $amount, 2 );
|
||||
$consumed = round( $consumed + $amount, 2 );
|
||||
}
|
||||
|
||||
if ( $consumed > 0.0 ) {
|
||||
$this->credits->consume( $studentId, $consumed );
|
||||
}
|
||||
|
||||
return $applied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the client-side payment step for a freshly created registration.
|
||||
* For a card payment a Stripe PaymentIntent is created (or replayed
|
||||
|
||||
Reference in New Issue
Block a user