Add weekly and monthly scheduled billing for offerings #105
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|---------|---------------------------------------------|-----------------------------|
|
||||
|
||||
@@ -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)
|
||||
@@ -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'] ?? '' ) ) );
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> $modes Billing modes to include (e.g. weekly, monthly).
|
||||
* @return list<Enrollment>
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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<string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Payment> $pending
|
||||
* @return list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}>
|
||||
*/
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
/**
|
||||
* Emails a student a single itemised notice for every payment the daily billing
|
||||
* scan generated for them in one run, so a student billed for several lessons on
|
||||
* the same day receives one email with a line per item and a grand total — never
|
||||
* one email per lesson.
|
||||
*/
|
||||
class PaymentDueMailer {
|
||||
|
||||
/**
|
||||
* Send one student their consolidated due-payment notice for the current scan.
|
||||
* The optional `$reference` is the shared notice-batch code the student can quote
|
||||
* on a lump-sum e-transfer so the studio can reconcile it to these payments.
|
||||
*
|
||||
* @param list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}> $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;
|
||||
}
|
||||
}
|
||||
@@ -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<int> $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(
|
||||
|
||||
@@ -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<int> $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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Generates the pending payments that scheduled-billing offerings (weekly /
|
||||
* monthly) owe as they come due, then emails each student one itemised notice.
|
||||
*
|
||||
* Runs from the daily WP-Cron action `us_generate_due_payments`. It is
|
||||
* self-healing: every run re-scans from the current ledger state, so a missed
|
||||
* day is simply picked up the next time. Dedup keeps a second run from
|
||||
* double-billing — private lessons via `us_lessons.payment_id`, group enrolments
|
||||
* via `us_payments.period_key`.
|
||||
*/
|
||||
class ScheduledBillingRunner {
|
||||
|
||||
public const HOOK = 'us_generate_due_payments';
|
||||
|
||||
public function __construct(
|
||||
private PaymentService $payments,
|
||||
private BookingRepository $bookings,
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
private PaymentDueMailer $mailer,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
add_action( self::HOOK, [ $this, 'run' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate every payment now due and send the consolidated notices.
|
||||
*/
|
||||
public function run(): void {
|
||||
$now = $this->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<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $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<string, list<array{lesson_id: int, student_id: int, instructor_id: int, currency: string, etransfer: ?string, title: string, price: float, start: \DateTimeImmutable}>> $monthly
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $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<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $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<array{start: string, end: string}> $windows
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $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<array{start: string, end: string}> $windows
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $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<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $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<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -85,8 +85,10 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<th><label for="billing_mode"><?php esc_html_e('Billing', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<select name="billing_mode" id="billing_mode">
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>"><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_ONE_TIME); ?>" <?php echo $editing && Offering::BILLING_ONE_TIME === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('One-time at booking', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_FULL_TERM); ?>" <?php echo $editing && Offering::BILLING_FULL_TERM === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Full term upfront', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_WEEKLY); ?>" <?php echo $editing && Offering::BILLING_WEEKLY === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Weekly — due 24h before each lesson', 'unsupervised-schedular'); ?></option>
|
||||
<option value="<?php echo esc_attr(Offering::BILLING_MONTHLY); ?>" <?php echo $editing && Offering::BILLING_MONTHLY === $editing->billingMode ? 'selected' : ''; ?>><?php esc_html_e('Monthly — billed on the 1st for that month\'s lessons', 'unsupervised-schedular'); ?></option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -5,13 +5,13 @@ if (! defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/** @var list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}> $rows */
|
||||
/** @var list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}> $groups */
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1><?php esc_html_e('Payments', 'unsupervised-schedular'); ?></h1>
|
||||
<p class="description"><?php esc_html_e('Pending payments awaiting confirmation. Marking one received confirms the booking and emails a receipt. You can correct the e-transfer email here if the student sent it elsewhere.', 'unsupervised-schedular'); ?></p>
|
||||
<p class="description"><?php esc_html_e('Pending payments awaiting confirmation. Marking one received confirms the booking and emails a receipt. You can correct the e-transfer email here if the student sent it elsewhere. Payments billed together on one notice are grouped under a reference — a single lump-sum e-transfer covers every payment in the group.', 'unsupervised-schedular'); ?></p>
|
||||
|
||||
<?php if (empty($rows)) : ?>
|
||||
<?php if (empty($groups)) : ?>
|
||||
<p><?php esc_html_e('No pending payments.', 'unsupervised-schedular'); ?></p>
|
||||
<?php else : ?>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
@@ -26,24 +26,41 @@ if (! defined('ABSPATH')) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $row) : ?>
|
||||
<tr>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_payment_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="mark_paid">
|
||||
<input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['id']); ?>">
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
<td><?php echo esc_html($row['for']); ?></td>
|
||||
<td><?php echo esc_html($row['amount']); ?></td>
|
||||
<td><?php echo esc_html($row['method']); ?></td>
|
||||
<td><input type="email" name="etransfer_email" class="regular-text" value="<?php echo esc_attr($row['etransfer_email']); ?>"></td>
|
||||
<td>
|
||||
<button type="submit" class="button button-small button-primary">
|
||||
<?php esc_html_e('Mark received', 'unsupervised-schedular'); ?>
|
||||
</button>
|
||||
<?php foreach ($groups as $group) : ?>
|
||||
<?php if ($group['is_group']) : ?>
|
||||
<tr>
|
||||
<td colspan="6" style="background:#f0f6fc;">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: 1: notice reference code, 2: lump-sum total, 3: number of payments. */
|
||||
esc_html__('Grouped notice %1$s — one lump-sum e-transfer of %2$s covers the %3$d payments below.', 'unsupervised-schedular'),
|
||||
'<strong>' . esc_html($group['reference']) . '</strong>',
|
||||
'<strong>' . esc_html($group['total']) . '</strong>',
|
||||
count($group['rows'])
|
||||
);
|
||||
?>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($group['rows'] as $row) : ?>
|
||||
<tr>
|
||||
<form method="post">
|
||||
<?php wp_nonce_field('usc_payment_action'); ?>
|
||||
<input type="hidden" name="usc_action" value="mark_paid">
|
||||
<input type="hidden" name="payment_id" value="<?php echo esc_attr((string) $row['id']); ?>">
|
||||
<td><?php echo esc_html($row['student']); ?></td>
|
||||
<td><?php echo esc_html($row['for']); ?></td>
|
||||
<td><?php echo esc_html($row['amount']); ?></td>
|
||||
<td><?php echo esc_html($row['method']); ?></td>
|
||||
<td><input type="email" name="etransfer_email" class="regular-text" value="<?php echo esc_attr($row['etransfer_email']); ?>"></td>
|
||||
<td>
|
||||
<button type="submit" class="button button-small button-primary">
|
||||
<?php esc_html_e('Mark received', 'unsupervised-schedular'); ?>
|
||||
</button>
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueMailer;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class PaymentDueMailerTest extends TestCase
|
||||
{
|
||||
private function student(string $email): \WP_User
|
||||
{
|
||||
$student = Mockery::mock(\WP_User::class);
|
||||
$student->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('[email protected]'), []));
|
||||
}
|
||||
|
||||
public function testConsolidatesItemsWithGrandTotal(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
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('[email protected]'), $items));
|
||||
}
|
||||
|
||||
public function testIncludesReferenceWhenProvided(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
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('[email protected]'), $items, 'REF12345'));
|
||||
}
|
||||
|
||||
public function testIncludesEtransferDestination(): void
|
||||
{
|
||||
Functions\expect('wp_mail')
|
||||
->once()
|
||||
->with(
|
||||
'[email protected]',
|
||||
Mockery::type('string'),
|
||||
Mockery::on(static fn (string $body): bool => str_contains($body, '[email protected]'))
|
||||
)
|
||||
->andReturn(true);
|
||||
|
||||
$items = [[ 'label' => 'Piano', 'amount' => 35.0, 'currency' => 'CAD', 'due_date' => '2026-07-15', 'etransfer_email' => '[email protected]' ]];
|
||||
|
||||
self::assertTrue((new PaymentDueMailer())->send($this->student('[email protected]'), $items));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Payment;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueMailer;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class ScheduledBillingRunnerTest extends TestCase
|
||||
{
|
||||
private PaymentService $payments;
|
||||
private BookingRepository $bookings;
|
||||
private EnrollmentRepository $enrollments;
|
||||
private OfferingRepository $offerings;
|
||||
private PaymentDueMailer $mailer;
|
||||
private ScheduledBillingRunner $runner;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->mailer = Mockery::mock(PaymentDueMailer::class);
|
||||
|
||||
// Defaults: nothing to bill unless a test says otherwise.
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([])->byDefault();
|
||||
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([])->byDefault();
|
||||
$this->mailer->shouldReceive('send')->andReturn(true)->byDefault();
|
||||
$this->payments->shouldReceive('assignNoticeBatch')->byDefault();
|
||||
|
||||
Functions\when('wp_generate_uuid4')->justReturn('abcdef12-3456-7890-abcd-ef1234567890');
|
||||
|
||||
$student = Mockery::mock(\WP_User::class);
|
||||
$student->user_email = '[email protected]';
|
||||
Functions\when('get_userdata')->justReturn($student);
|
||||
|
||||
$this->runner = new ScheduledBillingRunner(
|
||||
$this->payments,
|
||||
$this->bookings,
|
||||
$this->enrollments,
|
||||
$this->offerings,
|
||||
$this->mailer
|
||||
);
|
||||
}
|
||||
|
||||
private function now(string $mysql): void
|
||||
{
|
||||
Functions\when('current_time')->justReturn($mysql);
|
||||
}
|
||||
|
||||
private function pending(int $id, string $due): Payment
|
||||
{
|
||||
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, dueDate: $due, id: $id);
|
||||
}
|
||||
|
||||
private function lessonRow(int $id, string $mode, string $start, float $price, int $offeringId = 9): object
|
||||
{
|
||||
return (object) [
|
||||
'id' => (string) $id,
|
||||
'student_id' => '5',
|
||||
'instructor_id' => '3',
|
||||
'offering_id' => (string) $offeringId,
|
||||
'start_dt' => $start,
|
||||
'billing_mode' => $mode,
|
||||
'title' => 'Piano',
|
||||
'price' => (string) $price,
|
||||
'currency' => 'CAD',
|
||||
'etransfer_email' => '[email protected]',
|
||||
];
|
||||
}
|
||||
|
||||
public function testPrivateWeeklyBillsLessonWithin24h(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
|
||||
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 101, 5, 3, 35.0, 'CAD', '[email protected]', '2026-07-14', '2026-07-15')
|
||||
->andReturn($this->pending(500, '2026-07-14'));
|
||||
|
||||
$this->mailer->shouldReceive('send')->once();
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testPrivateWeeklySkipsLessonBeyond24h(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
|
||||
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-18 18:00:00', 35.0) ]);
|
||||
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
$this->mailer->shouldNotReceive('send');
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testPrivateMonthlyGroupsLessonsIntoOnePayment(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([
|
||||
$this->lessonRow(201, Offering::BILLING_MONTHLY, '2026-07-07 18:00:00', 30.0),
|
||||
$this->lessonRow(202, Offering::BILLING_MONTHLY, '2026-07-14 18:00:00', 30.0),
|
||||
$this->lessonRow(203, Offering::BILLING_MONTHLY, '2026-07-21 18:00:00', 30.0),
|
||||
]);
|
||||
|
||||
// One payment for the month: 3 x 30, due on the 1st, linked to the earliest.
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_LESSON, 201, 5, 3, 90.0, 'CAD', '[email protected]', '2026-07-01', '2026-07')
|
||||
->andReturn($this->pending(600, '2026-07-01'));
|
||||
|
||||
// The other two lessons are pointed at the same payment so they are not re-billed.
|
||||
$this->bookings->shouldReceive('setPaymentId')->once()->with(202, 600);
|
||||
$this->bookings->shouldReceive('setPaymentId')->once()->with(203, 600);
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testPrivateMonthlySkipsFutureMonth(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
|
||||
->andReturn([ $this->lessonRow(301, Offering::BILLING_MONTHLY, '2026-08-04 18:00:00', 30.0) ]);
|
||||
|
||||
$this->payments->shouldNotReceive('createForRegistration');
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testGroupWeeklyBillsDueSessionsOnly(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
|
||||
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_WEEKLY, '2026-07-07', '2026-07-21'));
|
||||
|
||||
// Sessions Jul 7 (due Jul 6) and Jul 14 (due Jul 13) are due by Jul 15; Jul 21 is not.
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-07')->andReturn(false);
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-14')->andReturn(false);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-06', '2026-07-07')
|
||||
->andReturn($this->pending(700, '2026-07-06'));
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
|
||||
->andReturn($this->pending(701, '2026-07-13'));
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
public function testGroupWeeklyDedupSkipsExistingPeriod(): void
|
||||
{
|
||||
$this->now('2026-07-15 09:00:00');
|
||||
$enrollment = new Enrollment(offeringId: 9, studentId: 5, instructorId: 3, id: 44);
|
||||
$this->enrollments->shouldReceive('findActiveByBillingModes')->andReturn([ $enrollment ]);
|
||||
$this->offerings->shouldReceive('findById')->with(9)->andReturn($this->groupOffering(Offering::BILLING_WEEKLY, '2026-07-07', '2026-07-21'));
|
||||
|
||||
// First session already billed; only the second generates a payment.
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-07')->andReturn(true);
|
||||
$this->payments->shouldReceive('scheduledPaymentExists')->with(Payment::REG_ENROLLMENT, 44, '2026-07-14')->andReturn(false);
|
||||
|
||||
$this->payments->shouldReceive('createForRegistration')
|
||||
->once()
|
||||
->with(Payment::REG_ENROLLMENT, 44, 5, 3, 20.0, 'CAD', null, '2026-07-13', '2026-07-14')
|
||||
->andReturn($this->pending(701, '2026-07-13'));
|
||||
|
||||
$this->runner->run();
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user