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 = 'a@b.test'; 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' => 'pay@studio.test', ]; } 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', 'pay@studio.test', '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', 'pay@studio.test', '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(); } public function testGroupMonthlyBillsMonthTotal(): 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: 4 sessions x 20, due on the 1st. $this->payments->shouldReceive('createForRegistration') ->once() ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 80.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, ); } }