Files
unsupervised-scheduler/tests/Unit/Payment/ScheduledBillingRunnerTest.php
T
thatguygriffandClaude Opus 5 bfdc3b3380
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Coding Standards (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
Bill a monthly group class its fee once per month
A monthly group class multiplied its price by the sessions falling in the month,
the same rule private lessons use — so a class priced at 40.00 CAD meeting
weekly was billed 160.00 CAD on the 1st, and no studio could quote the price on
a class card without lying about it.

A group class is now billed its fee once for the month however many times it
meets, which is what the card quotes and what the student ticks to agree to.
Private lessons keep the per-lesson rule: their price is a per-lesson fee, and
that is why the card quotes it per lesson.

The session count still labels the month on the student's payment notice; it no
longer prices it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 15:22:46 -03:00

337 lines
15 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentDueMailer;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class ScheduledBillingRunnerTest extends TestCase
{
private PaymentService $payments;
private BookingRepository $bookings;
private EnrollmentRepository $enrollments;
private OfferingRepository $offerings;
private PaymentDueMailer $mailer;
private ScheduledBillingRunner $runner;
protected function setUp(): void
{
parent::setUp();
$this->payments = Mockery::mock(PaymentService::class);
$this->bookings = Mockery::mock(BookingRepository::class);
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
$this->mailer = Mockery::mock(PaymentDueMailer::class);
// Defaults: nothing to bill unless a test says otherwise.
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([])->byDefault();
$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');
$student = Mockery::mock(\WP_User::class);
$student->user_email = '[email protected]';
Functions\when('get_userdata')->justReturn($student);
$this->runner = new ScheduledBillingRunner(
$this->payments,
$this->bookings,
$this->enrollments,
$this->offerings,
$this->mailer
);
}
private function now(string $mysql): void
{
Functions\when('current_time')->justReturn($mysql);
}
private function pending(int $id, string $due): Payment
{
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: $due, id: $id);
}
private function lessonRow(int $id, string $mode, string $start, float $price, int $offeringId = 9): object
{
return (object) [
'id' => (string) $id,
'student_id' => '5',
'instructor_id' => '3',
'offering_id' => (string) $offeringId,
'start_dt' => $start,
'billing_mode' => $mode,
'title' => 'Piano',
'price' => (string) $price,
'currency' => 'CAD',
'etransfer_email' => '[email protected]',
];
}
public function testPrivateWeeklyBillsLessonWithin24h(): 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) ]);
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 101, 5, 3, 35.0, 'CAD', '[email protected]', '2026-07-14', '2026-07-15')
->andReturn($this->pending(500, '2026-07-14'));
$this->mailer->shouldReceive('send')->once();
$this->runner->run();
}
public function testPrivateWeeklySkipsLessonBeyond24h(): void
{
$this->now('2026-07-15 09:00:00');
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-18 18:00:00', 35.0) ]);
$this->payments->shouldNotReceive('createForRegistration');
$this->mailer->shouldNotReceive('send');
$this->runner->run();
}
public function testPrivateMonthlyGroupsLessonsIntoOnePayment(): void
{
$this->now('2026-07-15 09:00:00');
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([
$this->lessonRow(201, Offering::BILLING_MONTHLY, '2026-07-07 18:00:00', 30.0),
$this->lessonRow(202, Offering::BILLING_MONTHLY, '2026-07-14 18:00:00', 30.0),
$this->lessonRow(203, Offering::BILLING_MONTHLY, '2026-07-21 18:00:00', 30.0),
]);
// One payment for the month: 3 x 30, due on the 1st, linked to the earliest.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_LESSON, 201, 5, 3, 90.0, 'CAD', '[email protected]', '2026-07-01', '2026-07')
->andReturn($this->pending(600, '2026-07-01'));
// The other two lessons are pointed at the same payment so they are not re-billed.
$this->bookings->shouldReceive('setPaymentId')->once()->with(202, 600);
$this->bookings->shouldReceive('setPaymentId')->once()->with(203, 600);
$this->runner->run();
}
public function testPrivateMonthlySkipsFutureMonth(): void
{
$this->now('2026-07-15 09:00:00');
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
->andReturn([ $this->lessonRow(301, Offering::BILLING_MONTHLY, '2026-08-04 18:00:00', 30.0) ]);
$this->payments->shouldNotReceive('createForRegistration');
$this->runner->run();
}
public function testGroupWeeklyBillsDueSessionsOnly(): void
{
$this->now('2026-07-15 09:00:00');
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_WEEKLY, '2026-07-07', '2026-07-21'));
// Sessions Jul 7 (due Jul 6) and Jul 14 (due Jul 13) are due by Jul 15; Jul 21 is not.
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-07')->andReturn(false);
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-14')->andReturn(false);
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-06', '2026-07-07')
->andReturn($this->pending(700, '2026-07-06'));
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
->andReturn($this->pending(701, '2026-07-13'));
$this->runner->run();
}
public function testGroupWeeklyDedupSkipsExistingPeriod(): void
{
$this->now('2026-07-15 09:00:00');
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_WEEKLY, '2026-07-07', '2026-07-21'));
// First session already billed; only the second generates a payment.
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-07')->andReturn(true);
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-14')->andReturn(false);
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
->andReturn($this->pending(701, '2026-07-13'));
$this->runner->run();
}
/**
* A monthly group class is priced per month, not per session: the same fee
* is charged whether the class meets four times in the month or once. This
* is what the class card quotes and what the student agrees to pay.
*/
public function testGroupMonthlyBillsTheMonthlyFeeOnceHoweverManySessions(): void
{
$this->now('2026-07-15 09:00:00');
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
// 4 Tuesday sessions in July.
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_MONTHLY, '2026-07-07', '2026-07-28'));
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
// One payment of the monthly fee — not 4 x 20 — due on the 1st.
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
->andReturn($this->pending(800, '2026-07-01'));
$this->runner->run();
}
/**
* The per-month fee does not shrink for a short month either — a month with
* a single session is billed the same as a month with four.
*/
public function testGroupMonthlyBillsTheSameFeeForAMonthWithOneSession(): void
{
$this->now('2026-07-15 09:00:00');
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
// A single July session.
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_MONTHLY, '2026-07-07', '2026-07-07'));
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07')->andReturn(false);
$this->payments->shouldReceive('createForRegistration')
->once()
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-01', '2026-07')
->andReturn($this->pending(800, '2026-07-01'));
$this->runner->run();
}
public function testCompPaymentIsNotBucketed(): 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) ]);
// A comp student's payment comes back paid — no due notice should be sent.
$comp = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_COMP, Payment::STATUS_PAID, dueDate: '2026-07-14', id: 900);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($comp);
$this->mailer->shouldNotReceive('send');
$this->runner->run();
}
public function testConsolidatesAllItemsIntoOneEmailPerStudent(): 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) ]);
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_WEEKLY, '2026-07-14', '2026-07-14'));
$this->payments->shouldReceive('scheduledPaymentExists')->andReturn(false);
$this->payments->shouldReceive('createForRegistration')->andReturn($this->pending(500, '2026-07-14'), $this->pending(501, '2026-07-13'));
// Same student billed twice in one run -> exactly one email with both items,
// and both payments tagged with one shared notice-batch reference.
$this->payments->shouldReceive('assignNoticeBatch')
->once()
->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'), 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();
}
private function groupOffering(string $mode, string $termStart, string $termEnd): Offering
{
return new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Ensemble',
price: 20.0,
currency: 'CAD',
billingMode: $mode,
durationMinutes: 60,
termStart: $termStart,
termEnd: $termEnd,
classTime: '16:00:00',
id: 9,
);
}
}