justReturn('2026-09-15 09:00:00'); $this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->payments = Mockery::mock(PaymentRepository::class); $this->reconciler = new BillingModeReconciler($this->enrollments, $this->payments); } private function offering(string $billingMode, ?int $id = 9): Offering { return new Offering( instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Ensemble', price: 150.0, billingMode: $billingMode, id: $id, ); } private function enrollment(int $id, int $studentId = 5): Enrollment { return new Enrollment(offeringId: 9, studentId: $studentId, instructorId: 3, id: $id); } public function testAdoptsUpfrontChargeIntoCurrentMonthWhenClassBecomesMonthly(): void { $this->enrollments->shouldReceive('findActiveByOffering')->with(9)->andReturn([$this->enrollment(7)]); $this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(true); $this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 7, '2026-09')->andReturn(false); // The up-front charge is stamped into 2026-09, due on the 1st, so the scan // treats September as already billed for this enrolment. $this->payments->shouldReceive('claimPeriodForUnscheduled') ->once() ->with(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01') ->andReturn(1); self::assertSame(1, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY))); } public function testDoesNothingWhenBillingModeDidNotChangeIntoMonthly(): void { $this->enrollments->shouldNotReceive('findActiveByOffering'); $this->payments->shouldNotReceive('claimPeriodForUnscheduled'); // Already monthly -> monthly (e.g. an unrelated title edit): no transition. self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_MONTHLY), $this->offering(Offering::BILLING_MONTHLY))); } public function testDoesNothingWhenSwitchingToWeekly(): void { $this->enrollments->shouldNotReceive('findActiveByOffering'); $this->payments->shouldNotReceive('claimPeriodForUnscheduled'); // Weekly bills per session as sessions come due; there is no single upfront // period an enrolment charge maps onto, so it is left alone. self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_WEEKLY))); } public function testSkipsEnrolmentWithoutAnUpfrontCharge(): void { $this->enrollments->shouldReceive('findActiveByOffering')->with(9)->andReturn([$this->enrollment(7)]); // Enrolled after the class was already scheduled, or never charged upfront: // nothing to adopt. $this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(false); $this->payments->shouldNotReceive('claimPeriodForUnscheduled'); self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY))); } public function testSkipsEnrolmentAlreadyBilledForThisMonthByTheScan(): void { $this->enrollments->shouldReceive('findActiveByOffering')->with(9)->andReturn([$this->enrollment(7)]); $this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(true); // The scan already made a 2026-09 charge: adopting the upfront one too would // leave two charges for the month — the very thing we are preventing. $this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 7, '2026-09')->andReturn(true); $this->payments->shouldNotReceive('claimPeriodForUnscheduled'); self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY))); } public function testReconcilesEveryActiveEnrolment(): void { $this->enrollments->shouldReceive('findActiveByOffering')->with(9) ->andReturn([$this->enrollment(7, 5), $this->enrollment(8, 6)]); $this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(true); $this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 7, '2026-09')->andReturn(false); $this->payments->shouldReceive('claimPeriodForUnscheduled')->with(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01')->andReturn(1); $this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 8)->andReturn(true); $this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 8, '2026-09')->andReturn(false); $this->payments->shouldReceive('claimPeriodForUnscheduled')->with(Payment::REG_ENROLLMENT, 8, '2026-09', '2026-09-01')->andReturn(1); self::assertSame(2, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY))); } public function testIgnoresNonGroupClassOfferings(): void { $before = new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano', billingMode: Offering::BILLING_ONE_TIME, id: 9); $after = new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano', billingMode: Offering::BILLING_MONTHLY, id: 9); $this->enrollments->shouldNotReceive('findActiveByOffering'); self::assertSame(0, $this->reconciler->reconcile($before, $after)); } }