Files
unsupervised-scheduler/tests/Unit/Offering/OfferingTest.php
T
thatguygriffandClaude Opus 4.8 4328e8fb5f
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
Add weekly and monthly scheduled billing for offerings
Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:06:37 -03:00

335 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class OfferingTest extends TestCase
{
public function testConstructorAndProperties(): void
{
$offering = new Offering(
instructorId: 5,
kind: Offering::KIND_PRIVATE_LESSON,
title: '30-min Piano',
price: 35.00,
billingMode: Offering::BILLING_ONE_TIME,
durationMinutes: 30,
id: 42,
);
self::assertSame(5, $offering->instructorId);
self::assertSame(Offering::KIND_PRIVATE_LESSON, $offering->kind);
self::assertSame('30-min Piano', $offering->title);
self::assertSame(35.00, $offering->price);
self::assertSame(30, $offering->durationMinutes);
self::assertSame(42, $offering->id);
}
public function testNormalizeDateAcceptsRealDates(): void
{
self::assertSame('2026-09-08', Offering::normalizeDate('2026-09-08'));
}
public function testNormalizeDateRejectsGarbage(): void
{
self::assertNull(Offering::normalizeDate(''));
self::assertNull(Offering::normalizeDate('not-a-date'));
self::assertNull(Offering::normalizeDate('2026-02-30'));
self::assertNull(Offering::normalizeDate('2026-09-08 10:00'));
}
public function testWeeklyTermEndAddsOneWeekPerExtraSession(): void
{
self::assertSame('2026-11-10', Offering::weeklyTermEnd('2026-09-08', 10));
}
public function testWeeklyTermEndOfSingleSessionIsTheStartDate(): void
{
self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 1));
self::assertSame('2026-09-08', Offering::weeklyTermEnd('2026-09-08', 0));
}
public function testNormalizeTimeAcceptsHtmlTimeInput(): void
{
self::assertSame('16:30:00', Offering::normalizeTime('16:30'));
self::assertSame('09:00:00', Offering::normalizeTime('09:00:00'));
}
public function testNormalizeTimeRejectsGarbage(): void
{
self::assertNull(Offering::normalizeTime(''));
self::assertNull(Offering::normalizeTime('25:00'));
self::assertNull(Offering::normalizeTime('not-a-time'));
}
public function testSessionWindowsForOneOffClass(): void
{
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Recital',
durationMinutes: 90,
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
);
self::assertSame(
[['start' => '2026-09-08 16:00:00', 'end' => '2026-09-08 17:30:00']],
$offering->sessionWindows()
);
}
public function testSessionWindowsWalksWeeklyTerm(): void
{
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-22',
classTime: '16:00:00',
);
$windows = $offering->sessionWindows();
self::assertCount(3, $windows);
self::assertSame('2026-09-08 16:00:00', $windows[0]['start']);
self::assertSame('2026-09-22 16:00:00', $windows[2]['start']);
self::assertSame('2026-09-22 17:00:00', $windows[2]['end']);
}
public function testSessionWindowsEmptyWhenScheduleIncomplete(): void
{
// No class time.
$noTime = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
durationMinutes: 60,
termStart: '2026-09-08',
termEnd: '2026-09-08',
);
self::assertSame([], $noTime->sessionWindows());
// No duration.
$noDuration = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2026-09-08',
termEnd: '2026-09-08',
classTime: '16:00:00',
);
self::assertSame([], $noDuration->sessionWindows());
}
public function testDefaults(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
self::assertSame(0.0, $offering->price);
self::assertSame('CAD', $offering->currency);
self::assertSame(Offering::BILLING_ONE_TIME, $offering->billingMode);
self::assertNull($offering->durationMinutes);
self::assertFalse($offering->allowWeekly);
self::assertNull($offering->capacity);
self::assertTrue($offering->isActive);
self::assertNull($offering->id);
}
public function testFromRowMapsCorrectlyAndCastsNullables(): void
{
$row = (object) [
'id' => '7',
'instructor_id' => '3',
'kind' => Offering::KIND_GROUP_CLASS,
'title' => 'Year Choir',
'description' => 'Weekly choir',
'duration_minutes' => null,
'price' => '120.00',
'currency' => 'CAD',
'billing_mode' => Offering::BILLING_FULL_TERM,
'allow_weekly' => '0',
'capacity' => '20',
'term_start' => '2026-09-01',
'term_end' => '2027-06-30',
'schedule_note' => 'Tuesdays 4:00pm',
'etransfer_email' => null,
'cancellation_cutoff_hours' => '48',
'is_active' => '1',
];
$offering = Offering::fromRow($row);
self::assertSame(7, $offering->id);
self::assertSame(3, $offering->instructorId);
self::assertNull($offering->durationMinutes);
self::assertSame(120.00, $offering->price);
self::assertSame(20, $offering->capacity);
self::assertSame(Offering::BILLING_FULL_TERM, $offering->billingMode);
self::assertSame(48, $offering->cancellationCutoffHours);
self::assertTrue($offering->isActive);
}
public function testFromRowMapsNullCancellationCutoff(): void
{
$row = (object) [
'id' => '7',
'instructor_id' => '3',
'kind' => Offering::KIND_PRIVATE_LESSON,
'title' => '30 min lesson',
'description' => null,
'duration_minutes' => '30',
'price' => '40.00',
'currency' => 'CAD',
'billing_mode' => Offering::BILLING_ONE_TIME,
'allow_weekly' => '0',
'capacity' => null,
'term_start' => null,
'term_end' => null,
'schedule_note' => null,
'etransfer_email' => null,
'cancellation_cutoff_hours' => null,
'is_active' => '1',
];
$offering = Offering::fromRow($row);
self::assertNull($offering->cancellationCutoffHours);
}
public function testToArrayContainsExpectedKeys(): void
{
$offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', id: 10);
$arr = $offering->toArray();
foreach (['id', 'instructor_id', 'kind', 'title', 'price', 'billing_mode', 'access_mode', 'is_active'] as $key) {
self::assertArrayHasKey($key, $arr);
}
}
public function testDefaultsToPublicAccess(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', id: 10);
self::assertSame(Offering::ACCESS_PUBLIC, $offering->accessMode);
self::assertFalse($offering->isInviteOnly());
}
public function testInviteOnlyAccessIsReported(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', accessMode: Offering::ACCESS_INVITE_ONLY, id: 10);
self::assertTrue($offering->isInviteOnly());
self::assertSame(Offering::ACCESS_INVITE_ONLY, $offering->toArray()['access_mode']);
}
public function testFromRowReadsInviteOnlyAccessMode(): void
{
$row = (object) [
'id' => '7',
'instructor_id' => '3',
'kind' => Offering::KIND_GROUP_CLASS,
'title' => 'Private Choir',
'description' => null,
'duration_minutes' => null,
'price' => '0.00',
'currency' => 'CAD',
'billing_mode' => Offering::BILLING_FULL_TERM,
'allow_weekly' => '0',
'capacity' => null,
'term_start' => null,
'term_end' => null,
'schedule_note' => null,
'etransfer_email' => null,
'cancellation_cutoff_hours' => null,
'access_mode' => Offering::ACCESS_INVITE_ONLY,
'is_active' => '1',
];
self::assertTrue(Offering::fromRow($row)->isInviteOnly());
}
public function testToArrayIncludesEtransferEmailByDefault(): void
{
$offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: '[email protected]', id: 10);
self::assertArrayHasKey('etransfer_email', $offering->toArray());
self::assertSame('[email protected]', $offering->toArray()['etransfer_email']);
}
public function testToArrayOmitsEtransferEmailWhenExcluded(): void
{
$offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: '[email protected]', id: 10);
self::assertArrayNotHasKey('etransfer_email', $offering->toArray(includeEtransferEmail: false));
}
public function testValidKindAndBillingConstants(): void
{
self::assertContains(Offering::KIND_PRIVATE_LESSON, Offering::VALID_KINDS);
self::assertContains(Offering::KIND_GROUP_CLASS, Offering::VALID_KINDS);
self::assertContains(Offering::BILLING_ONE_TIME, Offering::VALID_BILLING_MODES);
self::assertContains(Offering::BILLING_FULL_TERM, Offering::VALID_BILLING_MODES);
self::assertContains(Offering::BILLING_WEEKLY, Offering::VALID_BILLING_MODES);
self::assertContains(Offering::BILLING_MONTHLY, Offering::VALID_BILLING_MODES);
}
public function testIsScheduledBillingOnlyForWeeklyAndMonthly(): void
{
self::assertFalse((new Offering(1, Offering::KIND_PRIVATE_LESSON, 'A', billingMode: Offering::BILLING_ONE_TIME))->isScheduledBilling());
self::assertFalse((new Offering(1, Offering::KIND_PRIVATE_LESSON, 'A', billingMode: Offering::BILLING_FULL_TERM))->isScheduledBilling());
self::assertTrue((new Offering(1, Offering::KIND_PRIVATE_LESSON, 'A', billingMode: Offering::BILLING_WEEKLY))->isScheduledBilling());
self::assertTrue((new Offering(1, Offering::KIND_GROUP_CLASS, 'A', billingMode: Offering::BILLING_MONTHLY))->isScheduledBilling());
}
public function testEffectiveEnrollmentDeadlineDefaultsToTermStart(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08');
self::assertSame('2026-09-08', $offering->effectiveEnrollmentDeadline());
}
public function testEffectiveEnrollmentDeadlineUsesExplicitValueWhenSet(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31');
self::assertSame('2026-08-31', $offering->effectiveEnrollmentDeadline());
}
public function testEffectiveEnrollmentDeadlineIsNullWithoutDates(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
self::assertNull($offering->effectiveEnrollmentDeadline());
}
public function testIsEnrollmentOpenOnAndBeforeTheDeadlineDay(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31');
self::assertTrue($offering->isEnrollmentOpen('2026-08-30'));
self::assertTrue($offering->isEnrollmentOpen('2026-08-31'));
self::assertFalse($offering->isEnrollmentOpen('2026-09-01'));
}
public function testIsEnrollmentOpenAlwaysTrueWithoutADeadline(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
self::assertTrue($offering->isEnrollmentOpen('2099-01-01'));
}
public function testToArrayIncludesEnrollmentDeadline(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', enrollmentDeadline: '2026-08-31', id: 10);
self::assertSame('2026-08-31', $offering->toArray()['enrollment_deadline']);
}
}