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

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:
2026-07-24 15:32:20 -03:00
co-authored by Claude Opus 4.8
parent 3f9aef7746
commit e8e66eef3c
30 changed files with 1210 additions and 80 deletions
+4
View File
@@ -42,6 +42,10 @@ class StudentActionsTest extends TestCase
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CANCELLED)->andReturn(true);
$this->availability->shouldReceive('release')->once()->with(7)->andReturn(true);
$this->payments->shouldReceive('voidPending')->once()->with(40);
// A paid lesson is credited; the cancelled lesson value object is handed over.
$this->payments->shouldReceive('creditForCancelledLesson')
->once()
->with(Mockery::on(static fn (Lesson $l): bool => $l->id === 12 && $l->paymentId === 40));
self::assertTrue($this->actions->cancelLesson(12, 5));
}
+36 -1
View File
@@ -5,6 +5,8 @@ namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Mockery;
use Unsupervised\Schedular\Auth\StudentHistory;
use Unsupervised\Schedular\Payment\Credit;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
@@ -27,6 +29,7 @@ class StudentHistoryTest extends TestCase
private AnswerRepository&Mockery\MockInterface $answers;
private QuestionRepository&Mockery\MockInterface $questions;
private PaymentRepository&Mockery\MockInterface $payments;
private CreditRepository&Mockery\MockInterface $credits;
private StudentHistory $history;
protected function setUp(): void
@@ -39,6 +42,7 @@ class StudentHistoryTest extends TestCase
$this->answers = Mockery::mock(AnswerRepository::class);
$this->questions = Mockery::mock(QuestionRepository::class);
$this->payments = Mockery::mock(PaymentRepository::class);
$this->credits = Mockery::mock(CreditRepository::class);
$this->history = new StudentHistory(
$this->acceptances,
@@ -46,7 +50,8 @@ class StudentHistoryTest extends TestCase
$this->policyVersions,
$this->answers,
$this->questions,
$this->payments
$this->payments,
$this->credits
);
}
@@ -215,4 +220,34 @@ class StudentHistoryTest extends TestCase
self::assertSame('Enrolment #3', $rows[0]['context']);
self::assertSame('—', $rows[0]['receipt']);
}
public function testCreditBalanceDelegatesToRepository(): void
{
$this->credits->shouldReceive('availableBalance')->once()->with(5)->andReturn(45.0);
self::assertSame(45.0, $this->history->creditBalance(5));
}
public function testCreditsBuildDisplayRows(): void
{
$this->credits->shouldReceive('findByStudent')->once()->with(5)->andReturn([
new Credit(5, 33.00, 13.00, 'CAD', 12, 77, 'Credit for cancelled lesson #77', Credit::STATUS_AVAILABLE, '2026-07-01 09:00:00', id: 300),
]);
$rows = $this->history->credits(5);
self::assertSame(
[
[
'created_at' => '2026-07-01 09:00:00',
'amount' => 33.00,
'remaining' => 13.00,
'currency' => 'CAD',
'reason' => 'Credit for cancelled lesson #77',
'status' => Credit::STATUS_AVAILABLE,
],
],
$rows
);
}
}
@@ -48,6 +48,9 @@ class BookingEndpointTest extends TestCase
$this->payments = Mockery::mock(PaymentService::class);
$this->settings = Mockery::mock(StudioSettings::class);
$this->settings->shouldReceive('cancellationCutoffHours')->andReturn(24)->byDefault();
// Crediting a cancelled paid lesson is exercised in dedicated tests; other
// cancellation paths simply allow the call.
$this->payments->shouldReceive('creditForCancelledLesson')->andReturn(null)->byDefault();
$this->endpoint = new BookingEndpoint(
$this->availability,
@@ -472,6 +475,24 @@ class BookingEndpointTest extends TestCase
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
}
public function testCancelCreditsThePaidLesson(): void
{
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true);
$this->availability->shouldReceive('release')->with(10)->once()->andReturn(true);
$this->payments->shouldReceive('voidPending')->with(12)->once();
// The cancelled lesson (the value object, so its payment_id is intact) is
// handed to the credit path.
$this->payments->shouldReceive('creditForCancelledLesson')
->once()
->with(Mockery::on(static fn (Lesson $l): bool => $l->id === 77 && $l->paymentId === 12));
$this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
}
public function testCancelWithinStudioCutoffIsRejected(): void
{
// Now (2026-06-01 10:00) is only 24h before a slot at 2026-06-02 10:00,
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Payment\Credit;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class CreditRepositoryTest extends TestCase
{
private \wpdb $db;
private CreditRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->db = Mockery::mock(\wpdb::class);
$this->db->prefix = 'wp_';
$this->repo = new CreditRepository($this->db);
}
public function testInsertReturnsId(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_credits',
Mockery::on(static function (array $d): bool {
return $d['student_id'] === 5
&& $d['amount'] === 33.0
&& $d['remaining'] === 33.0
&& $d['source_lesson_id'] === 77
&& $d['status'] === Credit::STATUS_AVAILABLE;
}),
Mockery::type('array')
);
$this->db->insert_id = 300;
$credit = new Credit(5, 33.0, 33.0, 'CAD', 12, 77, 'Credit for cancelled lesson #77');
self::assertSame(300, $this->repo->insert($credit));
}
public function testExistsForLessonReturnsTrueWhenRowFound(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/source_lesson_id = %d/'), 'wp_us_credits', 77)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('300');
self::assertTrue($this->repo->existsForLesson(77));
}
public function testExistsForLessonReturnsFalseWhenNone(): void
{
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->andReturn(null);
self::assertFalse($this->repo->existsForLesson(77));
}
public function testAvailableBalanceSumsRemaining(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/SUM\( remaining \)/'), 'wp_us_credits', 5, Credit::STATUS_AVAILABLE)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('45.00');
self::assertSame(45.0, $this->repo->availableBalance(5));
}
public function testConsumeDrawsDownOldestFirstAndMarksSpentConsumed(): void
{
// Two available credits ($20 then $30); consuming $35 empties the first and
// takes $15 from the second, leaving it $15 and still available.
$rows = [
(object) ['id' => '1', 'student_id' => '5', 'amount' => '20.00', 'remaining' => '20.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-06-01 09:00:00', 'updated_at' => null],
(object) ['id' => '2', 'student_id' => '5', 'amount' => '30.00', 'remaining' => '30.00', 'currency' => 'CAD', 'source_payment_id' => null, 'source_lesson_id' => null, 'reason' => null, 'status' => Credit::STATUS_AVAILABLE, 'created_at' => '2026-06-02 09:00:00', 'updated_at' => null],
];
Functions\expect('current_time')->with('mysql')->andReturn('2026-07-15 12:00:00');
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->once()->with('SELECT ...')->andReturn($rows);
// First credit fully spent -> consumed.
$this->db->shouldReceive('update')
->once()
->with('wp_us_credits', Mockery::on(static fn (array $d): bool => $d['remaining'] === 0.0 && $d['status'] === Credit::STATUS_CONSUMED), ['id' => 1], Mockery::type('array'), Mockery::type('array'));
// Second credit partly spent -> stays available with $15 remaining.
$this->db->shouldReceive('update')
->once()
->with('wp_us_credits', Mockery::on(static fn (array $d): bool => $d['remaining'] === 15.0 && $d['status'] === Credit::STATUS_AVAILABLE), ['id' => 2], Mockery::type('array'), Mockery::type('array'));
$this->repo->consume(5, 35.0);
}
public function testConsumeIgnoresNonPositiveAmount(): void
{
$this->db->shouldNotReceive('get_results');
$this->db->shouldNotReceive('update');
$this->repo->consume(5, 0.0);
}
}
@@ -74,6 +74,46 @@ class PaymentDueMailerTest extends TestCase
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items, 'REF12345'));
}
public function testCreditReducesTheTotalDue(): void
{
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
Mockery::type('string'),
Mockery::on(static function (string $body): bool {
// Line shows the full 35.00; credit line shows -20.00; total due 15.00.
return str_contains($body, '35.00')
&& str_contains($body, '-CAD 20.00')
&& str_contains($body, 'Total due: CAD 15.00');
})
)
->andReturn(true);
$items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => '[email protected]' ]];
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items, 'REF1', 20.0));
}
public function testCreditCoveringEverythingLeavesZeroDueAndNoEtransferLine(): void
{
Functions\expect('wp_mail')
->once()
->with(
'[email protected]',
Mockery::type('string'),
Mockery::on(static function (string $body): bool {
return str_contains($body, 'Total due: CAD 0.00')
&& ! str_contains($body, '[email protected]');
})
)
->andReturn(true);
$items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => '[email protected]' ]];
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items, '', 35.0));
}
public function testIncludesEtransferDestination(): void
{
Functions\expect('wp_mail')
+151 -1
View File
@@ -9,6 +9,8 @@ use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Booking\Lesson;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\Credit;
use Unsupervised\Schedular\Payment\CreditRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
@@ -26,6 +28,7 @@ class PaymentServiceTest extends TestCase
private EnrollmentRepository $enrollments;
private StudioSettings $settings;
private StripeGateway $stripe;
private CreditRepository $credits;
private PaymentService $service;
protected function setUp(): void
@@ -39,6 +42,7 @@ class PaymentServiceTest extends TestCase
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->settings = Mockery::mock(StudioSettings::class);
$this->stripe = Mockery::mock(StripeGateway::class);
$this->credits = Mockery::mock(CreditRepository::class);
$this->settings->shouldReceive('etransferEmail')->andReturn('');
$this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault();
// Confirming a lesson looks it up to detect a weekly series; single
@@ -52,7 +56,8 @@ class PaymentServiceTest extends TestCase
$this->bookings,
$this->enrollments,
$this->settings,
$this->stripe
$this->stripe,
$this->credits
);
Functions\when('get_userdata')->justReturn(false);
@@ -340,6 +345,151 @@ class PaymentServiceTest extends TestCase
self::assertTrue($this->service->handleWebhook('{}', 'sig'));
}
public function testCreditForCancelledLessonCreditsWholeTotalOfSingleLessonPayment(): void
{
// A paid single-lesson payment: the whole total (incl. tax) is credited.
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, taxRate: 10.0, taxAmount: 3.00, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(false);
$this->credits->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->studentId === 5
&& $c->amount === 33.00
&& $c->remaining === 33.00
&& $c->sourceLessonId === 77))
->andReturn(300);
$this->credits->shouldReceive('findById')->with(300)->andReturn(
new Credit(5, 33.00, 33.00, 'CAD', 12, 77, id: 300)
);
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 77);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonSplitsSharedMonthlyPayment(): void
{
// A monthly scheduled charge covering 3 lessons: one cancellation credits a third.
$paid = new Payment(5, 3, Payment::REG_LESSON, 201, 90.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, dueDate: '2026-07-01', id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(3);
$this->credits->shouldReceive('existsForLesson')->with(202)->andReturn(false);
$this->credits->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
->andReturn(301);
$this->credits->shouldReceive('findById')->with(301)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 202, id: 301));
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, paymentId: 12, id: 202);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonSkipsUnpaidPayment(): void
{
$pending = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($pending);
$this->credits->shouldNotReceive('insert');
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, paymentId: 12, id: 77);
self::assertNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonSkipsWhenNoPayment(): void
{
$this->credits->shouldNotReceive('insert');
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, id: 77);
self::assertNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonSkipsAlreadyCredited(): void
{
$paid = new Payment(5, 3, Payment::REG_LESSON, 77, 30.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12);
$this->payments->shouldReceive('findById')->with(12)->andReturn($paid);
$this->bookings->shouldReceive('countByPaymentId')->with(12)->andReturn(1);
$this->credits->shouldReceive('existsForLesson')->with(77)->andReturn(true);
$this->credits->shouldNotReceive('insert');
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, paymentId: 12, id: 77);
self::assertNull($this->service->creditForCancelledLesson($lesson));
}
public function testCreditForCancelledLessonUsesSeriesSizeForUpfrontSeries(): void
{
// A non-anchor series lesson has no payment_id of its own; the anchor's
// upfront (unscheduled) payment covers the whole 4-lesson series.
$anchorPayment = new Payment(5, 3, Payment::REG_LESSON, 40, 120.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PAID, id: 12);
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 40)->andReturn($anchorPayment);
$this->payments->shouldReceive('findById')->with(12)->andReturn($anchorPayment);
$this->bookings->shouldReceive('countBySeries')->with(40)->andReturn(4);
$this->credits->shouldReceive('existsForLesson')->with(43)->andReturn(false);
$this->credits->shouldReceive('insert')
->once()
->with(Mockery::on(static fn (Credit $c): bool => $c->amount === 30.00))
->andReturn(302);
$this->credits->shouldReceive('findById')->with(302)->andReturn(new Credit(5, 30.00, 30.00, 'CAD', 12, 43, id: 302));
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, recurrence: Lesson::RECURRENCE_WEEKLY, seriesId: 40, paymentId: null, id: 43);
self::assertNotNull($this->service->creditForCancelledLesson($lesson));
}
public function testApplyCreditsReturnsEmptyWhenNoBalance(): void
{
$this->credits->shouldReceive('availableBalance')->with(5)->andReturn(0.0);
self::assertSame([], $this->service->applyCredits(5, [$this->pending(500, 40.00)]));
}
public function testApplyCreditsPartiallyCoversWithoutMarkingPaid(): void
{
// $30 credit against a $40 charge: applied but still owing, so it stays pending.
$this->credits->shouldReceive('availableBalance')->with(5)->andReturn(30.0);
$this->payments->shouldReceive('addCreditApplied')->once()->with(500, 30.0)->andReturn(true);
$this->payments->shouldNotReceive('markPaid');
$this->credits->shouldReceive('consume')->once()->with(5, 30.0);
$applied = $this->service->applyCredits(5, [$this->pending(500, 40.00)]);
self::assertSame([500 => 30.0], $applied);
}
public function testApplyCreditsFullyCoversMarksPaidByCreditAndConfirms(): void
{
// $50 credit against a $40 charge: fully covered -> settled + registration confirmed.
$this->credits->shouldReceive('availableBalance')->with(5)->andReturn(50.0);
$this->payments->shouldReceive('addCreditApplied')->once()->with(500, 40.0)->andReturn(true);
$this->payments->shouldReceive('markPaid')->once()->with(500, 'USC-500')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->credits->shouldReceive('consume')->once()->with(5, 40.0);
$applied = $this->service->applyCredits(5, [$this->pending(500, 40.00)]);
self::assertSame([500 => 40.0], $applied);
}
public function testApplyCreditsSpreadsAcrossChargesOldestFirst(): void
{
// $50 balance across two $40 charges: first fully covered, second partly.
$this->credits->shouldReceive('availableBalance')->with(5)->andReturn(50.0);
$this->payments->shouldReceive('addCreditApplied')->once()->with(500, 40.0)->andReturn(true);
$this->payments->shouldReceive('markPaid')->once()->with(500, 'USC-500')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->payments->shouldReceive('addCreditApplied')->once()->with(501, 10.0)->andReturn(true);
$this->credits->shouldReceive('consume')->once()->with(5, 50.0);
$applied = $this->service->applyCredits(5, [$this->pending(500, 40.00), $this->pending(501, 40.00)]);
self::assertSame([500 => 40.0, 501 => 10.0], $applied);
}
private function pending(int $id, float $amount): Payment
{
return new Payment(5, 3, Payment::REG_LESSON, 12, $amount, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: $id);
}
private function intentEvent(string $type, string $intentId): \Stripe\Event
{
$intent = \Stripe\PaymentIntent::constructFrom(['id' => $intentId, 'object' => 'payment_intent']);
+22
View File
@@ -73,6 +73,28 @@ class PaymentTest extends TestCase
self::assertSame(100.00, $payment->total());
}
public function testNetDueSubtractsAppliedCredit(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 100.00, taxRate: 13.0, taxAmount: 13.00, creditApplied: 40.00);
self::assertSame(113.00, $payment->total());
self::assertSame(73.00, $payment->netDue());
}
public function testNetDueFloorsAtZeroWhenCreditExceedsTotal(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 30.00, creditApplied: 50.00);
self::assertSame(0.0, $payment->netDue());
}
public function testNetDueEqualsTotalWithoutCredit(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 30.00);
self::assertSame(30.00, $payment->netDue());
}
public function testToSummaryArrayContainsOnlyClientFacingFields(): void
{
$summary = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toSummaryArray();
@@ -40,6 +40,8 @@ class ScheduledBillingRunnerTest extends TestCase
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([])->byDefault();
$this->mailer->shouldReceive('send')->andReturn(true)->byDefault();
$this->payments->shouldReceive('assignNoticeBatch')->byDefault();
// No account credit unless a test says otherwise.
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
Functions\when('wp_generate_uuid4')->justReturn('abcdef12-3456-7890-abcd-ef1234567890');
@@ -238,7 +240,52 @@ class ScheduledBillingRunnerTest extends TestCase
->with(Mockery::on(static fn (array $ids): bool => count($ids) === 2), Mockery::type('string'));
$this->mailer->shouldReceive('send')
->once()
->with(Mockery::type(\WP_User::class), Mockery::on(static fn (array $items): bool => count($items) === 2), Mockery::type('string'));
->with(Mockery::type(\WP_User::class), Mockery::on(static fn (array $items): bool => count($items) === 2), Mockery::type('string'), 0.0);
$this->runner->run();
}
public function testAppliesAccountCreditToTheRun(): void
{
$this->now('2026-07-15 09:00:00');
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
$payment = $this->pending(500, '2026-07-14');
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($payment);
// Student holds $20 credit, applied to the one $35 charge — still $15 owing,
// so the payment stays in the notice batch and the notice quotes the credit.
$this->payments->shouldReceive('applyCredits')
->once()
->with(5, Mockery::on(static fn (array $p): bool => count($p) === 1))
->andReturn([500 => 20.0]);
$this->payments->shouldReceive('assignNoticeBatch')
->once()
->with([500], Mockery::type('string'));
$this->mailer->shouldReceive('send')
->once()
->with(Mockery::type(\WP_User::class), Mockery::type('array'), Mockery::type('string'), 20.0);
$this->runner->run();
}
public function testCreditFullyCoveringAChargeLeavesItOutOfTheBatch(): void
{
$this->now('2026-07-15 09:00:00');
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
$payment = $this->pending(500, '2026-07-14');
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($payment);
// Credit covers the whole $35 charge: nothing owing, so no reconciliation
// batch and no reference on the (zero-balance) notice.
$this->payments->shouldReceive('applyCredits')->once()->andReturn([500 => 35.0]);
$this->payments->shouldReceive('assignNoticeBatch')->once()->with([], '');
$this->mailer->shouldReceive('send')
->once()
->with(Mockery::type(\WP_User::class), Mockery::type('array'), '', 35.0);
$this->runner->run();
}