From 4328e8fb5f284ea966ee79f23fc8f07e52e7efba Mon Sep 17 00:00:00 2001 From: James Griffin Date: Fri, 24 Jul 2026 12:06:37 -0300 Subject: [PATCH] Add weekly and monthly scheduled billing for offerings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/features/offerings.md | 8 +- docs/features/payments.md | 16 + docs/features/scheduled-billing.md | 90 +++++ src/Booking/BookingEndpoint.php | 34 +- src/Booking/BookingRepository.php | 39 ++ src/GroupClass/EnrollmentEndpoint.php | 5 +- src/GroupClass/EnrollmentRepository.php | 33 ++ src/Installer.php | 13 + src/Offering/Offering.php | 24 +- src/Payment/Payment.php | 18 + src/Payment/PaymentController.php | 67 +++- src/Payment/PaymentDueMailer.php | 92 +++++ src/Payment/PaymentRepository.php | 50 ++- src/Payment/PaymentService.php | 35 +- src/Payment/ScheduledBillingRunner.php | 365 ++++++++++++++++++ src/Plugin.php | 4 + src/Schema.php | 3 + templates/admin/offerings.php | 4 +- templates/admin/payments.php | 57 ++- tests/Unit/Booking/BookingEndpointTest.php | 74 ++++ tests/Unit/Booking/BookingRepositoryTest.php | 35 ++ .../GroupClass/EnrollmentEndpointTest.php | 16 + .../GroupClass/EnrollmentRepositoryTest.php | 38 ++ tests/Unit/Offering/OfferingTest.php | 10 + tests/Unit/Payment/PaymentDueMailerTest.php | 92 +++++ tests/Unit/Payment/PaymentRepositoryTest.php | 62 +++ tests/Unit/Payment/PaymentServiceTest.php | 11 + .../Payment/ScheduledBillingRunnerTest.php | 262 +++++++++++++ unsupervised-schedular.php | 5 +- 29 files changed, 1514 insertions(+), 48 deletions(-) create mode 100644 docs/features/scheduled-billing.md create mode 100644 src/Payment/PaymentDueMailer.php create mode 100644 src/Payment/ScheduledBillingRunner.php create mode 100644 tests/Unit/Payment/PaymentDueMailerTest.php create mode 100644 tests/Unit/Payment/ScheduledBillingRunnerTest.php diff --git a/docs/features/offerings.md b/docs/features/offerings.md index 8cb0861..96af2e1 100644 --- a/docs/features/offerings.md +++ b/docs/features/offerings.md @@ -15,7 +15,7 @@ An offering is anything a student can register for: a private-lesson type (30 or | `duration_minutes` | SMALLINT | Private lessons only (e.g. 30, 60); NULL for group classes | | `price` | DECIMAL(10,2) | Price in dollars | | `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` | -| `billing_mode` | VARCHAR(20) | `one_time` (single booking) or `full_term` (weekly / group) | +| `billing_mode` | VARCHAR(20) | `one_time`, `full_term`, `weekly`, or `monthly` (see Billing Mode below) | | `allow_weekly` | TINYINT(1) | Private only — may be reserved weekly for the term | | `capacity` | SMALLINT | Group only — max enrolments; NULL for private | | `term_start` | DATE | Group / term offerings — first day; NULL otherwise | @@ -31,6 +31,12 @@ An offering is anything a student can register for: a private-lesson type (30 or ## Billing Mode - `one_time` — charged once at booking (a single private lesson). - `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`. +- `weekly` — **not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan. +- `monthly` — **not** charged at registration; on the **1st of each month** a single pending payment is generated for every lesson that falls in that month (4 lessons ⇒ 4 × fee). + +`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the +booking/enrolment succeeds with no payment step, and payments are created later by the +daily `us_generate_due_payments` cron scan. See `scheduled-billing.md` and `payments.md`. ## Term Dates Group classes carry a term: `term_start` is the date of the first class and diff --git a/docs/features/payments.md b/docs/features/payments.md index 480cb22..172b5e4 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -88,6 +88,9 @@ After booking, the destination on a payment can be corrected per booking: | `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` | | `tax_rate` | DECIMAL(5,2) | HST rate % frozen at booking; editable until paid | | `tax_amount` | DECIMAL(10,2) | Computed tax in dollars (`amount × tax_rate / 100`) | +| `due_date` | DATE | When a *scheduled* payment is due; NULL = due at registration (`Payment::isScheduled()`) | +| `period_key` | VARCHAR(20) | Scheduled-billing dedup key: session date (weekly) or `YYYY-MM` (monthly); NULL otherwise | +| `notice_batch` | VARCHAR(32) | Shared reference for the payments one due-notice email covers, so a lump-sum e-transfer reconciles to them; NULL otherwise | | `etransfer_email` | VARCHAR(191) | Frozen e-transfer destination; editable until confirmed | | `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp | | `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` | @@ -102,6 +105,19 @@ After booking, the destination on a payment can be corrected per booking: 4. On transition to `paid`, `ReceiptMailer` assigns a `receipt_number`, emails the student a receipt, and stamps `receipt_sent_at`. 5. For an e-transfer, the studio admin later calls `PATCH /payments/{id}` to mark it `paid`, which triggers the same confirmation + receipt. +## Scheduled Billing (weekly / monthly) +`weekly` and `monthly` offerings are **not** charged at registration. The booking / +enrolment succeeds with `payment: null`; the lesson is confirmed (or the enrolment stays +active) immediately, and payments are generated later by the daily +`us_generate_due_payments` cron scan (`Payment\ScheduledBillingRunner`). Each generated +payment carries a `due_date` and `period_key`, flows through the same +`PaymentService::createForRegistration` (so HST, method resolution, e-transfer freezing +and comp auto-pay are identical), and the student is emailed one consolidated itemised +notice per scan (`Payment\PaymentDueMailer`). Because these payments are scheduled, +`PaymentService::voidPending` never voids them — cancelling one lesson leaves a shared +monthly charge (and every other lesson it covers) untouched, and never rebills. Full +model, dedup, and the four generation cases are documented in `scheduled-billing.md`. + ## REST API | Method | Endpoint | Permission | |---------|---------------------------------------------|-----------------------------| diff --git a/docs/features/scheduled-billing.md b/docs/features/scheduled-billing.md new file mode 100644 index 0000000..078bc80 --- /dev/null +++ b/docs/features/scheduled-billing.md @@ -0,0 +1,90 @@ +# Feature: Scheduled Billing (weekly / monthly) + +## Overview +Two offering billing modes defer payment past registration and generate pending +payments on a recurring schedule: + +- **`weekly`** — one payment per lesson, due **24 hours before** that lesson. +- **`monthly`** — one payment per calendar month, due on the **1st**, covering every + lesson that falls in the month (4 lessons ⇒ 4 × fee). + +Both apply to **private lessons** and **group classes**. At registration the +booking/enrolment succeeds with `payment: null` (no payment step); the lesson is +confirmed / the enrolment stays active immediately. Payments are created later by a daily +WP-Cron scan, and the student is emailed one consolidated notice per scan. Collection +uses the existing rails (e-transfer confirmed by the studio admin, or card) — there is no +automatic card charging. + +## The daily scan — `Payment\ScheduledBillingRunner` +Hooked to the WP-Cron action **`us_generate_due_payments`** (scheduled `daily` by +`Installer`, cleared on plugin deactivation). `run()` is self-healing: it re-derives +everything due from current ledger state each run, so a missed day is simply picked up +next time. Every payment is created through `PaymentService::createForRegistration` (HST, +method resolution, e-transfer freezing, comp auto-pay reused) with a `due_date` and +`period_key` set. + +### The four generation cases +| Source | When it bills | Amount | Dedup | +|--------|---------------|--------|-------| +| **Private weekly** | lesson `start_dt` ≤ now + 24h | 1 × fee | `us_lessons.payment_id` set on the lesson | +| **Private monthly** | the lesson's month's 1st ≤ today | (#lessons in month) × fee | `payment_id` set on every lesson in the month | +| **Group weekly** | session (from `Offering::sessionWindows()`) − 1 day ≤ now | 1 × fee | `us_payments.period_key` = session date | +| **Group monthly** | the month's 1st ≤ today | (#sessions in month) × fee | `period_key` = `YYYY-MM` | + +- Private lessons dedup on `us_lessons.payment_id IS NULL` — a lesson with no payment is + unbilled. A monthly group links its earliest lesson via `createForRegistration` and the + runner points the remaining lessons at the same payment. +- Group enrolments (one row per whole term) dedup on `period_key` via + `PaymentRepository::existsForPeriod()`, since one enrolment maps to many periodic + charges. +- Only offerings with a positive price are billed; cancelled lessons are excluded, so a + lesson cancelled before its payment is generated is simply never billed. + +### Late bookings charge at booking time +A single scheduled lesson booked **after** its due date has already passed is charged at +booking instead of deferred (`BookingEndpoint::scheduledDueHasPassed`): an extra monthly +lesson added to a month that was already billed (its 1st has arrived), or a weekly lesson +booked within 24 hours of the session. These create a normal at-registration payment (no +`due_date`), so the fee is collected once, at booking, and never billed late by the scan. +This applies only to single bookings — a weekly reservation series always defers, each +lesson billed by the scan on its own schedule. + +## Notification — `Payment\PaymentDueMailer` +As the runner creates each **pending** payment it appends an itemised line to that +student's notice bucket; after all cases run it sends **one** email per student with a +line per item (label · due date · amount) and a grand total, plus the e-transfer +destination(s). A student billed for several lessons on one day is emailed once, never +per lesson. Comp payments (auto-paid) are not bucketed. + +### Notice batch (lump-sum reconciliation) +All the payments in one student's notice are tagged with a shared **notice batch** +reference (`us_payments.notice_batch`, `PaymentRepository::assignNoticeBatch`), which is +printed on the email so the student can quote it. In the **Payments** admin queue those +payments are shown grouped under that reference with a combined lump-sum total +(`PaymentController::groupPending`), so when one e-transfer arrives for the whole notice +the admin can see exactly which pending payments — and therefore which bookings — it +covers. Each is still confirmed individually with **Mark received**. Legacy +at-registration payments have no batch and appear on their own. + +## Cancellation +Scheduled payments are never auto-voided. `PaymentService::voidPending` acts only on +legacy at-registration payments (`! Payment::isScheduled()`), so cancelling one lesson +never voids a shared monthly charge, never refunds, and never rebills. Refunds/credits +are a manual, admin-side decision. + +## Implementation +- Runner: `Unsupervised\Schedular\Payment\ScheduledBillingRunner` +- Notice email: `Unsupervised\Schedular\Payment\PaymentDueMailer` +- Finders: `Booking\BookingRepository::findUnbilledScheduledLessons`, + `GroupClass\EnrollmentRepository::findActiveByBillingModes` +- Dedup: `Payment\PaymentRepository::existsForPeriod` +- Session windows: `Offering\Offering::sessionWindows` +- Cron scheduling: `Installer::scheduleBilling`; cleared in `unsupervised-schedular.php` + deactivation hook. + +## Tests +- `tests/Unit/Payment/ScheduledBillingRunnerTest.php` +- `tests/Unit/Payment/PaymentDueMailerTest.php` +- `tests/Unit/Payment/PaymentRepositoryTest.php` (`existsForPeriod`, `due_date`/`period_key`) +- `tests/Unit/Payment/PaymentServiceTest.php` (`voidPending` skips scheduled) +- `tests/Unit/Booking/BookingEndpointTest.php` / `tests/Unit/GroupClass/EnrollmentEndpointTest.php` (deferred payment) diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 35832ef..60fe5af 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -259,7 +259,16 @@ class BookingEndpoint { $payment = null; $status = Lesson::STATUS_PENDING; - if ( $offering->price > 0.0 ) { + // Scheduled billing (weekly / monthly) normally defers payment to the daily + // scan, but a single lesson booked once its scheduled due date has already + // passed — e.g. an extra lesson added to a month that was already billed — is + // charged at booking instead, so it is never missed or billed late. + $chargeAtBooking = $offering->price > 0.0 && ( + ! $offering->isScheduledBilling() + || ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) ) + ); + + if ( $chargeAtBooking ) { // A full-term price already covers the whole reservation; a per-lesson // (one_time) price is owed once per occurrence actually claimed, so a // weekly reservation cannot hold a term while paying for one week. @@ -273,8 +282,10 @@ class BookingEndpoint { $status = Lesson::STATUS_CONFIRMED; } } else { - // Free offering: there is no payment step that would confirm these - // lessons later, so they are confirmed at booking time. + // Either a free offering, or scheduled billing (weekly / monthly) whose + // payment is deferred to the daily billing scan. Either way there is no + // payment step now to confirm the lessons, so the reserved slots are + // confirmed at booking time; the billing scan bills them when they come due. foreach ( $ids as $lessonId ) { $this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED ); } @@ -306,6 +317,23 @@ class BookingEndpoint { return $out; } + /** + * Whether a scheduled-billing offering's due date for a given session has + * already passed at booking time. Weekly bills 24 hours before the lesson; + * monthly bills on the 1st, so its due moment has passed once "now" is in the + * lesson's month or later. Only meaningful for weekly / monthly offerings. + */ + private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool { + $now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) ); + $start = new \DateTimeImmutable( $slotStart ); + + if ( Offering::BILLING_MONTHLY === $offering->billingMode ) { + return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' ); + } + + return $now >= $start->modify( '-1 day' ); + } + private function clientIp(): ?string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit. $ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php index dd5df72..065f7c9 100644 --- a/src/Booking/BookingRepository.php +++ b/src/Booking/BookingRepository.php @@ -204,6 +204,45 @@ class BookingRepository { return array_map( Lesson::fromRow( ... ), $rows ?? [] ); } + /** + * Not-yet-billed lessons on a scheduled-billing (weekly / monthly) offering: + * status not cancelled and no payment attached yet. Each row carries the slot + * start time and the offering's billing fields so the daily billing scan can + * decide what is due without a second query per lesson. Ordered by student, + * offering and time so the scan can group a student's monthly lessons cheaply. + * + * @return list<\stdClass> Rows: id, student_id, instructor_id, offering_id, + * start_dt, billing_mode, title, price, currency, + * etransfer_email. + */ + public function findUnbilledScheduledLessons(): array { + $avTable = str_replace( 'us_lessons', 'us_availability', $this->table ); + $offTable = str_replace( 'us_lessons', 'us_offerings', $this->table ); + + $rows = $this->db->get_results( + $this->db->prepare( + 'SELECT l.id, l.student_id, l.instructor_id, l.offering_id, + a.start_dt, + o.billing_mode, o.title, o.price, o.currency, o.etransfer_email + FROM %i l + JOIN %i a ON a.id = l.slot_id + JOIN %i o ON o.id = l.offering_id + WHERE l.status != %s + AND l.payment_id IS NULL + AND o.billing_mode IN ( %s, %s ) + ORDER BY l.student_id ASC, l.offering_id ASC, a.start_dt ASC', + $this->table, + $avTable, + $offTable, + Lesson::STATUS_CANCELLED, + \Unsupervised\Schedular\Offering\Offering::BILLING_WEEKLY, + \Unsupervised\Schedular\Offering\Offering::BILLING_MONTHLY + ) + ); + + return $rows ?? []; + } + public function setPaymentId( int $id, int $paymentId ): bool { return false !== $this->db->update( $this->table, diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index 74beb8e..fbfc413 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -129,8 +129,11 @@ class EnrollmentEndpoint { $this->access->markEnrolled( $offeringId, $studentId ); } + // Scheduled billing (weekly / monthly) is generated later by the daily + // billing scan, so nothing is charged at enrolment; the enrolment is active + // regardless of payment. $payment = null; - if ( $offering->price > 0.0 ) { + if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) { $payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); } diff --git a/src/GroupClass/EnrollmentRepository.php b/src/GroupClass/EnrollmentRepository.php index 39672ab..ed978da 100644 --- a/src/GroupClass/EnrollmentRepository.php +++ b/src/GroupClass/EnrollmentRepository.php @@ -132,6 +132,39 @@ class EnrollmentRepository { return array_map( Enrollment::fromRow( ... ), $rows ?? [] ); } + /** + * Active enrolments whose group class bills on a scheduled mode (weekly / + * monthly) — the source rows for the daily billing scan. Filtered by joining + * the offering so only classes actually on a scheduled plan are returned. + * + * @param list $modes Billing modes to include (e.g. weekly, monthly). + * @return list + */ + public function findActiveByBillingModes( array $modes ): array { + if ( [] === $modes ) { + return []; + } + + $offTable = str_replace( 'us_group_enrollments', 'us_offerings', $this->table ); + $placeholders = implode( ', ', array_fill( 0, count( $modes ), '%s' ) ); + + $rows = $this->db->get_results( + $this->db->prepare( + "SELECT e.* FROM %i e + JOIN %i o ON o.id = e.offering_id + WHERE e.status = %s + AND o.billing_mode IN ( {$placeholders} ) + ORDER BY e.student_id ASC, e.offering_id ASC", + $this->table, + $offTable, + Enrollment::STATUS_ACTIVE, + ...$modes + ) + ); + + return array_map( Enrollment::fromRow( ... ), $rows ?? [] ); + } + public function setPaymentId( int $id, int $paymentId ): bool { return false !== $this->db->update( $this->table, diff --git a/src/Installer.php b/src/Installer.php index ffa0380..6129913 100644 --- a/src/Installer.php +++ b/src/Installer.php @@ -5,6 +5,7 @@ namespace Unsupervised\Schedular; use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Availability\AvailabilityRepository; +use Unsupervised\Schedular\Payment\ScheduledBillingRunner; class Installer { @@ -12,10 +13,22 @@ class Installer { $this->createTables(); $this->migrateData(); ( new RoleManager() )->createRoles(); + $this->scheduleBilling(); flush_rewrite_rules(); update_option( 'us_schedular_version', USC_VERSION ); } + /** + * Ensure the daily scheduled-billing scan is registered with WP-Cron. Runs on + * activation and on every version-bump re-install, so an existing site that + * predates the feature picks the event up on its next deploy. + */ + private function scheduleBilling(): void { + if ( false === wp_next_scheduled( ScheduledBillingRunner::HOOK ) ) { + wp_schedule_event( time(), 'daily', ScheduledBillingRunner::HOOK ); + } + } + private function createTables(): void { global $wpdb; if ( ! $wpdb instanceof \wpdb ) { diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php index 2b6dcf9..04b2d64 100644 --- a/src/Offering/Offering.php +++ b/src/Offering/Offering.php @@ -20,12 +20,26 @@ class Offering { public const BILLING_ONE_TIME = 'one_time'; public const BILLING_FULL_TERM = 'full_term'; + /** Billed 24 hours before each lesson, on a recurring schedule (see scheduled-billing.md). */ + public const BILLING_WEEKLY = 'weekly'; + + /** Billed on the first of each month for every lesson that falls in the month. */ + public const BILLING_MONTHLY = 'monthly'; + /** * All valid billing modes. * * @var list */ - public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ]; + public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM, self::BILLING_WEEKLY, self::BILLING_MONTHLY ]; + + /** + * Billing modes whose payment is generated later by the daily billing scan + * rather than taken at registration. + * + * @var list + */ + public const SCHEDULED_BILLING_MODES = [ self::BILLING_WEEKLY, self::BILLING_MONTHLY ]; /** Listed in the public catalogue; anyone with `book_lesson` may enrol. */ public const ACCESS_PUBLIC = 'public'; @@ -71,6 +85,14 @@ class Offering { return self::ACCESS_INVITE_ONLY === $this->accessMode; } + /** + * Whether this offering's payment is deferred to the daily billing scan + * (weekly / monthly) instead of being taken at registration. + */ + public function isScheduledBilling(): bool { + return in_array( $this->billingMode, self::SCHEDULED_BILLING_MODES, true ); + } + /** * The last day on which a student may enrol in this group class. Defaults to * the first day of the class (`term_start`) when the instructor has not set an diff --git a/src/Payment/Payment.php b/src/Payment/Payment.php index 4f7ad4a..8e28eb9 100644 --- a/src/Payment/Payment.php +++ b/src/Payment/Payment.php @@ -44,6 +44,9 @@ class Payment { public readonly string $status = self::STATUS_PENDING, public readonly float $taxRate = 0.0, public readonly float $taxAmount = 0.0, + public readonly ?string $dueDate = null, + public readonly ?string $periodKey = null, + public readonly ?string $noticeBatch = null, public readonly ?string $etransferEmail = null, public readonly ?string $stripePaymentIntentId = null, public readonly ?string $receiptNumber = null, @@ -65,6 +68,9 @@ class Payment { status: Val::string( $row->status ), taxRate: Val::float( $row->tax_rate ), taxAmount: Val::float( $row->tax_amount ), + dueDate: Val::stringOrNull( $row->due_date ?? null ), + periodKey: Val::stringOrNull( $row->period_key ?? null ), + noticeBatch: Val::stringOrNull( $row->notice_batch ?? null ), etransferEmail: Val::stringOrNull( $row->etransfer_email ), stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ), receiptNumber: Val::stringOrNull( $row->receipt_number ), @@ -79,6 +85,15 @@ class Payment { return self::STATUS_PAID === $this->status; } + /** + * Whether this payment was generated by the daily billing scan (weekly / + * monthly) rather than taken at registration. Scheduled payments carry a due + * date, can cover several lessons, and are never auto-voided on cancellation. + */ + public function isScheduled(): bool { + return null !== $this->dueDate; + } + /** * Amount billed including tax. */ @@ -120,6 +135,9 @@ class Payment { 'currency' => $this->currency, 'method' => $this->method, 'status' => $this->status, + 'due_date' => $this->dueDate, + 'period_key' => $this->periodKey, + 'notice_batch' => $this->noticeBatch, 'receipt_number' => $this->receiptNumber, 'paid_at' => $this->paidAt, 'created_at' => $this->createdAt, diff --git a/src/Payment/PaymentController.php b/src/Payment/PaymentController.php index 7da12d2..bd2545d 100644 --- a/src/Payment/PaymentController.php +++ b/src/Payment/PaymentController.php @@ -33,22 +33,59 @@ class PaymentController { } } - $rows = array_map( - static function ( Payment $payment ): array { - $student = get_userdata( $payment->studentId ); - - return [ - 'id' => (int) $payment->id, - 'student' => $student ? $student->display_name : (string) $payment->studentId, - 'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency, - 'method' => $payment->method, - 'for' => $payment->registrationType . ' #' . $payment->registrationId, - 'etransfer_email' => (string) $payment->etransferEmail, - ]; - }, - $this->payments->findPending() - ); + $groups = $this->groupPending( $this->payments->findPending() ); include USC_PLUGIN_DIR . 'templates/admin/payments.php'; } + + /** + * Group pending payments by their shared notice batch, so payments the daily + * scan emailed a student together (and which a single lump-sum e-transfer + * covers) are shown as one group with a combined total. Payments with no batch + * — legacy at-registration e-transfers — are each their own single-item group. + * + * @param list $pending + * @return list}> + */ + private function groupPending( array $pending ): array { + $groups = []; + + foreach ( $pending as $payment ) { + $batch = (string) $payment->noticeBatch; + $key = '' !== $batch ? 'b:' . $batch : 's:' . (string) $payment->id; + + if ( ! isset( $groups[ $key ] ) ) { + $groups[ $key ] = [ + 'reference' => $batch, + 'currency' => $payment->currency, + 'total_raw' => 0.0, + 'rows' => [], + ]; + } + + $student = get_userdata( $payment->studentId ); + + $groups[ $key ]['total_raw'] += $payment->total(); + $groups[ $key ]['rows'][] = [ + 'id' => (int) $payment->id, + 'student' => $student ? $student->display_name : (string) $payment->studentId, + 'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency, + 'method' => $payment->method, + 'for' => $payment->registrationType . ' #' . $payment->registrationId, + 'etransfer_email' => (string) $payment->etransferEmail, + ]; + } + + return array_values( + array_map( + static fn( array $group ): array => [ + 'reference' => $group['reference'], + 'is_group' => count( $group['rows'] ) > 1, + 'total' => number_format( $group['total_raw'], 2 ) . ' ' . $group['currency'], + 'rows' => $group['rows'], + ], + $groups + ) + ); + } } diff --git a/src/Payment/PaymentDueMailer.php b/src/Payment/PaymentDueMailer.php new file mode 100644 index 0000000..4e64815 --- /dev/null +++ b/src/Payment/PaymentDueMailer.php @@ -0,0 +1,92 @@ + $items + * @return bool False when there is no recipient or nothing to bill. + */ + public function send( \WP_User $student, array $items, string $reference = '' ): bool { + if ( '' === (string) $student->user_email || [] === $items ) { + return false; + } + + $currency = (string) $items[0]['currency']; + $total = 0.0; + $lines = []; + $emails = []; + + foreach ( $items as $item ) { + $amount = (float) $item['amount']; + $total += $amount; + + $lines[] = sprintf( + /* translators: 1: item description, 2: due date, 3: currency, 4: amount */ + __( '- %1$s (due %2$s): %3$s %4$s', 'unsupervised-schedular' ), + (string) $item['label'], + $this->formatDate( $item['due_date'] ?? null ), + $currency, + number_format( $amount, 2 ) + ); + + $etransfer = (string) ( $item['etransfer_email'] ?? '' ); + if ( '' !== $etransfer ) { + $emails[ $etransfer ] = true; + } + } + + $body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n" + . implode( "\n", $lines ) . "\n\n" + . sprintf( + /* translators: 1: currency, 2: total amount */ + __( 'Total due: %1$s %2$s', 'unsupervised-schedular' ), + $currency, + number_format( $total, 2 ) + ); + + if ( [] !== $emails ) { + $body .= "\n\n" . sprintf( + /* translators: %s: e-transfer destination email address(es) */ + __( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ), + implode( ', ', array_keys( $emails ) ) + ); + } + + if ( '' !== $reference ) { + $body .= "\n\n" . sprintf( + /* translators: %s: payment reference code */ + __( 'Please include this reference with your payment: %s', 'unsupervised-schedular' ), + $reference + ); + } + + return (bool) wp_mail( $student->user_email, __( 'Payment due', 'unsupervised-schedular' ), $body ); + } + + /** + * Present a stored `Y-m-d` due date in a friendlier form; falls back to the + * raw value (or an empty string) when it is not a parseable date. + */ + private function formatDate( ?string $date ): string { + if ( null === $date || '' === $date ) { + return ''; + } + + $parsed = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date ); + + return false !== $parsed ? $parsed->format( 'M j, Y' ) : $date; + } +} diff --git a/src/Payment/PaymentRepository.php b/src/Payment/PaymentRepository.php index aced5b0..31fc9a1 100644 --- a/src/Payment/PaymentRepository.php +++ b/src/Payment/PaymentRepository.php @@ -25,6 +25,9 @@ class PaymentRepository { 'status' => $payment->status, 'tax_rate' => $payment->taxRate, 'tax_amount' => $payment->taxAmount, + 'due_date' => $payment->dueDate, + 'period_key' => $payment->periodKey, + 'notice_batch' => $payment->noticeBatch, 'etransfer_email' => $payment->etransferEmail, 'stripe_payment_intent_id' => $payment->stripePaymentIntentId, 'receipt_number' => $payment->receiptNumber, @@ -32,7 +35,7 @@ class PaymentRepository { 'paid_at' => $payment->paidAt, 'created_at' => current_time( 'mysql' ), ], - [ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ] + [ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] ); return $this->db->insert_id; @@ -119,6 +122,51 @@ class PaymentRepository { return $row ? Payment::fromRow( $row ) : null; } + /** + * Tag a set of payments with a shared notice-batch reference — the payments the + * daily scan emailed a student together, so the admin can see which pending + * payments a single lump-sum e-transfer covers. No-op for an empty id list. + * + * @param list $ids + */ + public function assignNoticeBatch( array $ids, string $batch ): void { + if ( [] === $ids ) { + return; + } + + $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) ); + $sql = $this->db->prepare( + "UPDATE %i SET notice_batch = %s WHERE id IN ( {$placeholders} )", + $this->table, + $batch, + ...$ids + ); + + if ( null !== $sql ) { + $this->db->query( $sql ); + } + } + + /** + * Whether a scheduled payment already exists for a registration and billing + * period. The daily billing scan uses this to avoid double-billing an + * enrolment for the same session (weekly) or month (monthly). A voided + * (`failed`) row still counts so a cancelled charge is not silently re-created. + */ + public function existsForPeriod( string $registrationType, int $registrationId, string $periodKey ): bool { + $found = $this->db->get_var( + $this->db->prepare( + 'SELECT id FROM %i WHERE registration_type = %s AND registration_id = %d AND period_key = %s LIMIT 1', + $this->table, + $registrationType, + $registrationId, + $periodKey + ) + ); + + return null !== $found; + } + public function findByRegistration( string $registrationType, int $registrationId ): ?Payment { $row = $this->db->get_row( $this->db->prepare( diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index d7beb18..7f31858 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -29,8 +29,12 @@ class PaymentService { * (card via Stripe — coming soon; e-transfer confirmed manually). The * e-transfer destination is frozen now from the offering override or the studio * default. Returns null when the registration has no price to charge. + * + * A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing + * scan (weekly / monthly) rather than taken at registration; both stay null for + * the pay-now flow. */ - public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null ): ?Payment { + public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null ): ?Payment { if ( $amount <= 0.0 ) { return null; } @@ -58,6 +62,8 @@ class PaymentService { status: $status, taxRate: $taxRate, taxAmount: $taxAmount, + dueDate: $dueDate, + periodKey: $periodKey, etransferEmail: $etransferEmail, ) ); @@ -71,6 +77,26 @@ class PaymentService { return $this->payments->findById( $id ); } + /** + * Whether a scheduled payment already exists for a registration and billing + * period — the daily billing scan's dedup check for group enrolments (whose one + * row maps to many periodic charges). Delegates to the ledger. + */ + public function scheduledPaymentExists( string $type, int $registrationId, string $periodKey ): bool { + return $this->payments->existsForPeriod( $type, $registrationId, $periodKey ); + } + + /** + * Tag the payments the daily scan emailed a student together with a shared + * notice-batch reference, so a lump-sum e-transfer can be reconciled to the + * pending payments it covers. Delegates to the ledger. + * + * @param list $ids + */ + public function assignNoticeBatch( array $ids, string $batch ): void { + $this->payments->assignNoticeBatch( $ids, $batch ); + } + /** * Studio-admin confirmation that a pending payment (e-transfer) was received. * Marks it paid, confirms the registration, and emails the receipt. @@ -92,7 +118,10 @@ class PaymentService { /** * Void the still-pending payment of a cancelled registration so it drops * out of the confirmation queue. Paid payments are left alone — refunds - * are a manual, admin-side decision. + * are a manual, admin-side decision. Scheduled payments (weekly / monthly) + * are also left alone: a monthly charge can cover several lessons and may + * already be collected, so cancelling one lesson must never void it or + * trigger a rebill. */ public function voidPending( ?int $paymentId ): void { if ( null === $paymentId ) { @@ -100,7 +129,7 @@ class PaymentService { } $payment = $this->payments->findById( $paymentId ); - if ( null !== $payment && Payment::STATUS_PENDING === $payment->status ) { + if ( null !== $payment && ! $payment->isScheduled() && Payment::STATUS_PENDING === $payment->status ) { $this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED ); } } diff --git a/src/Payment/ScheduledBillingRunner.php b/src/Payment/ScheduledBillingRunner.php new file mode 100644 index 0000000..5a1f941 --- /dev/null +++ b/src/Payment/ScheduledBillingRunner.php @@ -0,0 +1,365 @@ +now(); + + // One notice bucket per student, filled as pending payments are created and + // flushed to a single email at the end, so a student billed for several + // lessons on one day is emailed once — never once per lesson. $batchIds + // tracks the payment ids behind each student's bucket so they can be tagged + // with a shared reference for lump-sum e-transfer reconciliation. + $buckets = []; + $batchIds = []; + + $this->billPrivateLessons( $now, $buckets, $batchIds ); + $this->billGroupEnrollments( $now, $buckets, $batchIds ); + + $this->sendNotices( $buckets, $batchIds ); + } + + /** + * Private-lesson billing. Weekly lessons are billed one payment each once they + * are within 24 hours; monthly lessons are grouped per calendar month and billed + * one payment for the month once its 1st has arrived. + * + * @param array> $buckets + * @param array> $batchIds + */ + private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void { + $today = $now->format( 'Y-m-d' ); + $monthly = []; + + foreach ( $this->bookings->findUnbilledScheduledLessons() as $row ) { + $price = Val::float( $row->price ?? 0 ); + if ( $price <= 0.0 ) { + continue; + } + + $startRaw = Val::string( $row->start_dt ?? '' ); + $start = false !== strtotime( $startRaw ) ? new \DateTimeImmutable( $startRaw ) : null; + if ( null === $start ) { + continue; + } + + $lessonId = Val::int( $row->id ); + $studentId = Val::int( $row->student_id ); + $instructorId = Val::int( $row->instructor_id ); + $currency = Val::string( $row->currency ?? 'CAD' ); + $etransfer = Val::stringOrNull( $row->etransfer_email ?? null ); + $title = Val::string( $row->title ?? '' ); + + if ( Offering::BILLING_MONTHLY === Val::string( $row->billing_mode ?? '' ) ) { + $monthly[ $studentId . ':' . Val::int( $row->offering_id ) . ':' . $start->format( 'Y-m' ) ][] = [ + 'lesson_id' => $lessonId, + 'student_id' => $studentId, + 'instructor_id' => $instructorId, + 'currency' => $currency, + 'etransfer' => $etransfer, + 'title' => $title, + 'price' => $price, + 'start' => $start, + ]; + continue; + } + + // Weekly: due 24 hours before the lesson. + $due = $start->modify( '-1 day' ); + if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) { + continue; + } + + $this->bill( + $buckets, + $batchIds, + Payment::REG_LESSON, + $lessonId, + $studentId, + $instructorId, + $price, + $currency, + $etransfer, + $due->format( 'Y-m-d' ), + $start->format( 'Y-m-d' ), + $title . ' — ' . $start->format( 'M j, Y' ) + ); + } + + $this->billMonthlyLessonGroups( $today, $monthly, $buckets, $batchIds ); + } + + /** + * Bill each month's worth of monthly private lessons as one payment (count × + * fee), once the month's 1st has arrived. The payment links to the earliest + * lesson in the group; the rest are pointed at it so they are not re-billed. + * + * @param array> $monthly + * @param array> $buckets + * @param array> $batchIds + */ + private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets, array &$batchIds ): void { + foreach ( $monthly as $group ) { + $first = $group[0]['start']; + $monthStart = $first->format( 'Y-m-01' ); + + // Not billable until the 1st of the lesson's month has arrived. + if ( $monthStart > $today ) { + continue; + } + + $lessonIds = array_map( static fn( array $l ): int => $l['lesson_id'], $group ); + $anchorId = $lessonIds[0]; + $count = count( $group ); + + $payment = $this->bill( + $buckets, + $batchIds, + Payment::REG_LESSON, + $anchorId, + $group[0]['student_id'], + $group[0]['instructor_id'], + $group[0]['price'] * $count, + $group[0]['currency'], + $group[0]['etransfer'], + $monthStart, + $first->format( 'Y-m' ), + sprintf( + /* translators: 1: offering title, 2: month, 3: number of lessons */ + _n( '%1$s (%2$s): %3$d lesson', '%1$s (%2$s): %3$d lessons', $count, 'unsupervised-schedular' ), + $group[0]['title'], + $first->format( 'F Y' ), + $count + ) + ); + + if ( null === $payment ) { + continue; + } + + // createForRegistration links the anchor; point the rest of the month at + // the same payment so the next scan sees them as billed. + foreach ( array_slice( $lessonIds, 1 ) as $extraId ) { + $this->bookings->setPaymentId( $extraId, (int) $payment->id ); + } + } + } + + /** + * Group-class billing off each active enrolment's concrete session windows. + * Weekly bills one payment per session (24h before); monthly bills one payment + * per month (on the 1st) for that month's sessions. Dedup is by `period_key` + * since a single enrolment maps to many periodic charges. + * + * @param array> $buckets + * @param array> $batchIds + */ + private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void { + $today = $now->format( 'Y-m-d' ); + $offerings = []; + + foreach ( $this->enrollments->findActiveByBillingModes( Offering::SCHEDULED_BILLING_MODES ) as $enrollment ) { + $offeringId = $enrollment->offeringId; + if ( ! array_key_exists( $offeringId, $offerings ) ) { + $offerings[ $offeringId ] = $this->offerings->findById( $offeringId ); + } + $offering = $offerings[ $offeringId ]; + if ( null === $offering || $offering->price <= 0.0 ) { + continue; + } + + $windows = $offering->sessionWindows(); + if ( [] === $windows ) { + continue; + } + + if ( Offering::BILLING_MONTHLY === $offering->billingMode ) { + $this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets, $batchIds ); + } else { + $this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets, $batchIds ); + } + } + } + + /** + * Bill one payment per group-class session that is now within 24 hours. + * + * @param list $windows + * @param array> $buckets + * @param array> $batchIds + */ + private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void { + foreach ( $windows as $window ) { + $start = new \DateTimeImmutable( $window['start'] ); + $due = $start->modify( '-1 day' ); + if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) { + continue; + } + + $periodKey = $start->format( 'Y-m-d' ); + if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $periodKey ) ) { + continue; + } + + $this->bill( + $buckets, + $batchIds, + Payment::REG_ENROLLMENT, + (int) $enrollment->id, + $enrollment->studentId, + $enrollment->instructorId, + $offering->price, + $offering->currency, + $offering->etransferEmail, + $due->format( 'Y-m-d' ), + $periodKey, + $offering->title . ' — ' . $start->format( 'M j, Y' ) + ); + } + } + + /** + * Bill one payment per calendar month of a group class, once its 1st arrives. + * + * @param list $windows + * @param array> $buckets + * @param array> $batchIds + */ + private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + // Count this enrolment's sessions per calendar month. + $months = []; + foreach ( $windows as $window ) { + $start = new \DateTimeImmutable( $window['start'] ); + $months[ $start->format( 'Y-m' ) ] = ( $months[ $start->format( 'Y-m' ) ] ?? 0 ) + 1; + } + + foreach ( $months as $month => $count ) { + $monthStart = ( new \DateTimeImmutable( $month . '-01' ) )->format( 'Y-m-d' ); + if ( $monthStart > $today ) { + continue; + } + + if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $month ) ) { + continue; + } + + $this->bill( + $buckets, + $batchIds, + Payment::REG_ENROLLMENT, + (int) $enrollment->id, + $enrollment->studentId, + $enrollment->instructorId, + $offering->price * $count, + $offering->currency, + $offering->etransferEmail, + $monthStart, + $month, + sprintf( + /* translators: 1: offering title, 2: month, 3: number of sessions */ + _n( '%1$s (%2$s): %3$d session', '%1$s (%2$s): %3$d sessions', $count, 'unsupervised-schedular' ), + $offering->title, + ( new \DateTimeImmutable( $month . '-01' ) )->format( 'F Y' ), + $count + ) + ); + } + } + + /** + * Create one scheduled payment and, when it is pending (not a comp auto-pay), + * add an itemised line to the student's notice bucket and record its payment id + * for the shared notice batch. Returns the created payment, or null when there + * was nothing to charge. + * + * @param array> $buckets + * @param array> $batchIds + */ + private function bill( array &$buckets, array &$batchIds, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment { + $payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey ); + + if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) { + $buckets[ $studentId ][] = [ + 'label' => $label, + 'amount' => $payment->total(), + 'currency' => $payment->currency, + 'due_date' => $payment->dueDate, + 'etransfer_email' => $payment->etransferEmail, + ]; + $batchIds[ $studentId ][] = $payment->id; + } + + return $payment; + } + + /** + * Tag each student's payments with a shared batch reference and email them one + * itemised notice quoting it, so a lump-sum e-transfer can be reconciled to the + * exact pending payments it covers. + * + * @param array> $buckets + * @param array> $batchIds + */ + private function sendNotices( array $buckets, array $batchIds ): void { + foreach ( $buckets as $studentId => $items ) { + $reference = $this->reference(); + $this->payments->assignNoticeBatch( $batchIds[ $studentId ] ?? [], $reference ); + + $user = get_userdata( $studentId ); + if ( $user instanceof \WP_User ) { + $this->mailer->send( $user, $items, $reference ); + } + } + } + + /** + * A short, human-quotable reference shared by every payment in one student's + * notice, printed on the email and shown in the admin payments queue. + */ + private function reference(): string { + return strtoupper( substr( str_replace( '-', '', Val::string( wp_generate_uuid4() ) ), 0, 10 ) ); + } + + private function now(): \DateTimeImmutable { + $mysql = Val::string( current_time( 'mysql' ) ); + + return false !== strtotime( $mysql ) ? new \DateTimeImmutable( $mysql ) : new \DateTimeImmutable(); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index a2196cb..8a8da87 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -19,8 +19,10 @@ use Unsupervised\Schedular\GroupClass\GroupClassPage; use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\PaymentRepository; +use Unsupervised\Schedular\Payment\PaymentDueMailer; use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Payment\ReceiptMailer; +use Unsupervised\Schedular\Payment\ScheduledBillingRunner; use Unsupervised\Schedular\Payment\StripeGateway; use Unsupervised\Schedular\Payment\StudioSettings; use Unsupervised\Schedular\Policy\AcceptanceRepository; @@ -77,6 +79,8 @@ class Plugin { $registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess ); $groupClassPage = new GroupClassPage(); + ( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register(); + ( new UpdateChecker() )->register(); ( new RoleManager() )->register(); ( new RegistrationLoginGate() )->register(); diff --git a/src/Schema.php b/src/Schema.php index 98dbb8e..e745610 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -159,6 +159,9 @@ class Schema { status VARCHAR(20) NOT NULL DEFAULT 'pending', tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0, tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + due_date DATE DEFAULT NULL, + period_key VARCHAR(20) DEFAULT NULL, + notice_batch VARCHAR(32) DEFAULT NULL, etransfer_email VARCHAR(191) DEFAULT NULL, stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, receipt_number VARCHAR(50) DEFAULT NULL, diff --git a/templates/admin/offerings.php b/templates/admin/offerings.php index 86679b4..888ccb9 100644 --- a/templates/admin/offerings.php +++ b/templates/admin/offerings.php @@ -85,8 +85,10 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e diff --git a/templates/admin/payments.php b/templates/admin/payments.php index ae5de0c..827a033 100644 --- a/templates/admin/payments.php +++ b/templates/admin/payments.php @@ -5,13 +5,13 @@ if (! defined('ABSPATH')) { exit; } -/** @var list $rows */ +/** @var list}> $groups */ ?>

-

+

- +

@@ -26,24 +26,41 @@ if (! defined('ABSPATH')) { - - - - - - - - - - - - + - - + + + + + + + + + + + + + + + + +
- + + +
+ ' . esc_html($group['reference']) . '', + '' . esc_html($group['total']) . '', + count($group['rows']) + ); + ?>
+ +
diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index c053c53..a785e92 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -383,6 +383,80 @@ class BookingEndpointTest extends TestCase self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']); } + public function testScheduledBillingDefersPaymentAndConfirmsLesson(): void + { + // Weekly/monthly offerings are billed later by the daily scan, not at + // booking: no payment is created now, and the reserved lesson is confirmed. + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, billingMode: Offering::BILLING_WEEKLY, id: 8) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + $this->bookings->shouldReceive('insert')->once()->andReturn(77); + $this->gate->shouldReceive('record')->once(); + $this->payments->shouldNotReceive('createForRegistration'); + $this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']); + self::assertNull($result->get_data()['payment']); + } + + public function testMonthlyLessonInAlreadyBilledMonthChargesAtBooking(): void + { + // "now" is 2026-06-01; a monthly lesson booked into June (its billing 1st + // already reached) is an add-on and must be charged at booking, not deferred. + $this->availability->shouldReceive('findById')->with(10)->andReturn( + new AvailabilitySlot(instructorId: 3, startDt: '2026-06-20 10:00:00', endDt: '2026-06-20 11:00:00', offeringId: null, id: 10) + ); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + $this->bookings->shouldReceive('insert')->once()->andReturn(77); + $this->gate->shouldReceive('record')->once(); + + // Charged now, for a single lesson's fee, as a normal (non-scheduled) payment. + $this->payments->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_LESSON, 77, 5, 3, 45.0, 'CAD', null) + ->andReturn(new Payment(5, 3, Payment::REG_LESSON, 77, 45.0, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, id: 12)); + $this->bookings->shouldNotReceive('updateStatus'); + + $result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']); + self::assertNotNull($result->get_data()['payment']); + } + + public function testMonthlyLessonBeforeBillingDateDefersPayment(): void + { + // "now" is 2026-06-01; a monthly lesson for July is booked before July's 1st, + // so it defers to the daily scan (no payment now, lesson confirmed). + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 45.0, billingMode: Offering::BILLING_MONTHLY, id: 8) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + $this->bookings->shouldReceive('insert')->once()->andReturn(77); + $this->gate->shouldReceive('record')->once(); + $this->payments->shouldNotReceive('createForRegistration'); + $this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true); + + $result = $this->endpoint->book(new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']); + self::assertNull($result->get_data()['payment']); + } + public function testCancelByOwnerCancelsReleasesSlotAndVoidsPayment(): void { $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77); diff --git a/tests/Unit/Booking/BookingRepositoryTest.php b/tests/Unit/Booking/BookingRepositoryTest.php index 3282953..e356d69 100644 --- a/tests/Unit/Booking/BookingRepositoryTest.php +++ b/tests/Unit/Booking/BookingRepositoryTest.php @@ -184,6 +184,41 @@ class BookingRepositoryTest extends TestCase self::assertSame(15, $lessons[0]->id); } + public function testFindUnbilledScheduledLessonsJoinsOfferingAndFiltersUnbilled(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/l.status != %s.*l.payment_id IS NULL.*o.billing_mode IN \( %s, %s \)/s'), + 'wp_us_lessons', + 'wp_us_availability', + 'wp_us_offerings', + Lesson::STATUS_CANCELLED, + 'weekly', + 'monthly' + ) + ->andReturn('SELECT ...'); + + $row = (object) [ + 'id' => '15', + 'student_id' => '5', + 'instructor_id' => '3', + 'offering_id' => '9', + 'start_dt' => '2026-07-15 18:00:00', + 'billing_mode' => 'weekly', + 'title' => 'Piano', + 'price' => '35.00', + 'currency' => 'CAD', + 'etransfer_email' => null, + ]; + $this->db->shouldReceive('get_results')->andReturn([$row]); + + $rows = $this->repo->findUnbilledScheduledLessons(); + + self::assertCount(1, $rows); + self::assertSame('15', $rows[0]->id); + } + public function testCountUpcomingForStudent(): void { Functions\when('current_time')->justReturn('2026-06-08 12:00:00'); diff --git a/tests/Unit/GroupClass/EnrollmentEndpointTest.php b/tests/Unit/GroupClass/EnrollmentEndpointTest.php index d133aa7..24d7fa8 100644 --- a/tests/Unit/GroupClass/EnrollmentEndpointTest.php +++ b/tests/Unit/GroupClass/EnrollmentEndpointTest.php @@ -109,6 +109,22 @@ class EnrollmentEndpointTest extends TestCase ); } + public function testScheduledBillingEnrollmentDefersPayment(): void + { + // A monthly group class is billed later by the daily scan, so enrolment + // succeeds with no payment created now. + $offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: 120.0, billingMode: Offering::BILLING_MONTHLY, id: 8); + $this->offerings->shouldReceive('findById')->with(8)->andReturn($offering); + $this->expectSuccessfulEnrollment(); + $this->payments->shouldNotReceive('createForRegistration'); + + $result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(201, $result->get_status()); + self::assertNull($result->get_data()['payment']); + } + public function testRejectsEnrollmentAfterExplicitDeadline(): void { // current_time is stubbed to 2026-07-24, past the 2026-07-10 deadline. diff --git a/tests/Unit/GroupClass/EnrollmentRepositoryTest.php b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php index 4450a57..efba693 100644 --- a/tests/Unit/GroupClass/EnrollmentRepositoryTest.php +++ b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php @@ -108,6 +108,44 @@ class EnrollmentRepositoryTest extends TestCase self::assertInstanceOf(Enrollment::class, $all[0]); } + public function testFindActiveByBillingModesJoinsOfferingAndFiltersModes(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with( + Mockery::pattern('/e.status = %s.*o.billing_mode IN \( %s, %s \)/s'), + 'wp_us_group_enrollments', + 'wp_us_offerings', + Enrollment::STATUS_ACTIVE, + 'weekly', + 'monthly' + ) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_results')->andReturn([ + (object) [ + 'id' => '12', + 'offering_id' => '7', + 'student_id' => '5', + 'instructor_id' => '3', + 'status' => Enrollment::STATUS_ACTIVE, + 'payment_id' => null, + ], + ]); + + $found = $this->repo->findActiveByBillingModes(['weekly', 'monthly']); + + self::assertCount(1, $found); + self::assertInstanceOf(Enrollment::class, $found[0]); + } + + public function testFindActiveByBillingModesReturnsEmptyForNoModes(): void + { + $this->db->shouldNotReceive('prepare'); + + self::assertSame([], $this->repo->findActiveByBillingModes([])); + } + public function testUpdateStatusRejectsInvalid(): void { self::assertFalse($this->repo->updateStatus(1, 'bogus')); diff --git a/tests/Unit/Offering/OfferingTest.php b/tests/Unit/Offering/OfferingTest.php index a5286bd..78001a2 100644 --- a/tests/Unit/Offering/OfferingTest.php +++ b/tests/Unit/Offering/OfferingTest.php @@ -276,6 +276,16 @@ class OfferingTest extends TestCase 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 diff --git a/tests/Unit/Payment/PaymentDueMailerTest.php b/tests/Unit/Payment/PaymentDueMailerTest.php new file mode 100644 index 0000000..796100d --- /dev/null +++ b/tests/Unit/Payment/PaymentDueMailerTest.php @@ -0,0 +1,92 @@ +user_email = $email; + + return $student; + } + + public function testReturnsFalseWithoutRecipient(): void + { + $items = [[ 'label' => 'x', 'amount' => 10.0, 'currency' => 'CAD', 'due_date' => '2026-07-14', 'etransfer_email' => null ]]; + + self::assertFalse((new PaymentDueMailer())->send($this->student(''), $items)); + } + + public function testReturnsFalseWithNoItems(): void + { + self::assertFalse((new PaymentDueMailer())->send($this->student('a@b.test'), [])); + } + + public function testConsolidatesItemsWithGrandTotal(): void + { + Functions\expect('wp_mail') + ->once() + ->with( + 'a@b.test', + Mockery::type('string'), + Mockery::on(static function (string $body): bool { + return str_contains($body, 'Piano') + && str_contains($body, 'Jul 15, 2026') + && str_contains($body, 'Guitar') + && str_contains($body, 'Jul 22, 2026') + && str_contains($body, '35.00') + && str_contains($body, '40.00') + // 35 + 40 grand total + && str_contains($body, '75.00'); + }) + ) + ->andReturn(true); + + $items = [ + [ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => null ], + [ 'label' => 'Guitar', 'amount' => 40.0, 'currency' => 'CAD', 'due_date' => '2026-07-22', 'etransfer_email' => null ], + ]; + + self::assertTrue((new PaymentDueMailer())->send($this->student('a@b.test'), $items)); + } + + public function testIncludesReferenceWhenProvided(): void + { + Functions\expect('wp_mail') + ->once() + ->with( + 'a@b.test', + Mockery::type('string'), + Mockery::on(static fn (string $body): bool => str_contains($body, 'REF12345')) + ) + ->andReturn(true); + + $items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => null ]]; + + self::assertTrue((new PaymentDueMailer())->send($this->student('a@b.test'), $items, 'REF12345')); + } + + public function testIncludesEtransferDestination(): void + { + Functions\expect('wp_mail') + ->once() + ->with( + 'a@b.test', + Mockery::type('string'), + Mockery::on(static fn (string $body): bool => str_contains($body, 'pay@studio.test')) + ) + ->andReturn(true); + + $items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => 'pay@studio.test' ]]; + + self::assertTrue((new PaymentDueMailer())->send($this->student('a@b.test'), $items)); + } +} diff --git a/tests/Unit/Payment/PaymentRepositoryTest.php b/tests/Unit/Payment/PaymentRepositoryTest.php index ce75011..5a0e44e 100644 --- a/tests/Unit/Payment/PaymentRepositoryTest.php +++ b/tests/Unit/Payment/PaymentRepositoryTest.php @@ -44,6 +44,68 @@ class PaymentRepositoryTest extends TestCase self::assertSame(50, $this->repo->insert(new Payment(5, 3, Payment::REG_LESSON, 12, 35.00))); } + public function testInsertPersistsScheduledDueDateAndPeriodKey(): void + { + Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00'); + + $this->db->shouldReceive('insert') + ->once() + ->with( + 'wp_us_payments', + Mockery::on(static function (array $d): bool { + return $d['due_date'] === '2026-07-14' + && $d['period_key'] === '2026-07-15'; + }), + Mockery::type('array') + ); + $this->db->insert_id = 51; + + self::assertSame( + 51, + $this->repo->insert(new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, dueDate: '2026-07-14', periodKey: '2026-07-15')) + ); + } + + public function testExistsForPeriodReturnsTrueWhenRowFound(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/registration_type = %s AND registration_id = %d AND period_key = %s/'), 'wp_us_payments', Payment::REG_ENROLLMENT, 7, '2026-07') + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('91'); + + self::assertTrue($this->repo->existsForPeriod(Payment::REG_ENROLLMENT, 7, '2026-07')); + } + + public function testExistsForPeriodReturnsFalseWhenAbsent(): void + { + $this->db->shouldReceive('prepare')->once()->andReturn('SELECT ...'); + $this->db->shouldReceive('get_var')->once()->andReturn(null); + + self::assertFalse($this->repo->existsForPeriod(Payment::REG_ENROLLMENT, 7, '2026-08')); + } + + public function testAssignNoticeBatchUpdatesRows(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/SET notice_batch = %s WHERE id IN \( %d, %d \)/'), 'wp_us_payments', 'REF12345', 5, 6) + ->andReturn('UPDATE ...'); + + $this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(2); + + $this->repo->assignNoticeBatch([5, 6], 'REF12345'); + } + + public function testAssignNoticeBatchNoopForEmptyIds(): void + { + $this->db->shouldNotReceive('prepare'); + $this->db->shouldNotReceive('query'); + + $this->repo->assignNoticeBatch([], 'REF12345'); + } + public function testMarkPaidUpdatesStatusAndReceipt(): void { Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00'); diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php index 7bc865a..9bfed65 100644 --- a/tests/Unit/Payment/PaymentServiceTest.php +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -76,6 +76,17 @@ class PaymentServiceTest extends TestCase $this->service->voidPending(50); } + public function testVoidPendingLeavesScheduledPaymentAlone(): void + { + // A scheduled (weekly/monthly) payment can cover several lessons and may be + // collected: cancelling one lesson must never void it or trigger a rebill. + $scheduled = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: '2026-07-14', id: 60); + $this->payments->shouldReceive('findById')->with(60)->andReturn($scheduled); + $this->payments->shouldNotReceive('updateStatus'); + + $this->service->voidPending(60); + } + public function testVoidPendingLeavesPaidPaymentAlone(): void { // Refunds are manual: cancelling a paid lesson must not touch the ledger. diff --git a/tests/Unit/Payment/ScheduledBillingRunnerTest.php b/tests/Unit/Payment/ScheduledBillingRunnerTest.php new file mode 100644 index 0000000..b15ba45 --- /dev/null +++ b/tests/Unit/Payment/ScheduledBillingRunnerTest.php @@ -0,0 +1,262 @@ +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(); + + 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')); + + $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, + ); + } +} diff --git a/unsupervised-schedular.php b/unsupervised-schedular.php index 472bf1b..8f76ce8 100644 --- a/unsupervised-schedular.php +++ b/unsupervised-schedular.php @@ -3,7 +3,7 @@ * Plugin Name: Unsupervised Scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Description: Instructor/student lesson scheduling for WordPress. - * Version: 1.1.3 + * Version: 1.2.0 * Requires at least: 6.2 * Requires PHP: 8.1 * Author: Unsupervised @@ -21,7 +21,7 @@ if (! defined('ABSPATH')) { exit; } -define('USC_VERSION', '1.1.3'); +define('USC_VERSION', '1.2.0'); define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); @@ -35,6 +35,7 @@ register_activation_hook(__FILE__, static function (): void { }); register_deactivation_hook(__FILE__, static function (): void { + wp_clear_scheduled_hook('us_generate_due_payments'); flush_rewrite_rules(); }); -- 2.54.0